diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5780b52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,192 @@ +name: CI + +# CI Strategy: +# - Tests run on Linux, macOS, and Windows (cross-platform shared interfaces) +# - Go 1.25+ required (matches go.mod requirement) +# - Pure Go: zero external dependencies (only gputypes) +# +# Branch Strategy (GitHub Flow): +# - main branch: Production-ready code +# - Feature branches: Tested via pull_request trigger +# - Pull requests: Must pass all checks before merge + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + # Build verification - Cross-platform + build: + name: Build - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go-version: ['1.25'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Build all packages + run: go build ./... + + # Unit tests - Cross-platform + test: + name: Test - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go-version: ['1.25'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Download dependencies + run: go mod download + + - name: Run go vet + if: matrix.os == 'ubuntu-latest' + run: go vet ./... + + - name: Run tests with race detector + shell: bash + run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.txt + flags: unittests + name: codecov-gpucontext + + # Linting + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + args: --timeout=5m + + # Code formatting check + formatting: + name: Formatting + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Check formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "ERROR: The following files are not formatted:" + gofmt -l . + echo "" + echo "Run 'go fmt ./...' to fix formatting issues." + exit 1 + fi + echo "All files are properly formatted" + + # Dependency freshness check + # Uses go-mod-outdated (https://github.com/psampaz/go-mod-outdated) + deps: + name: Dependencies + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Install go-mod-outdated + run: go install github.com/psampaz/go-mod-outdated@latest + + - name: Check direct dependencies for updates + run: | + echo "## Direct Dependencies with Updates" + OUTDATED=$(go list -u -m -json all 2>/dev/null | go-mod-outdated -update -direct || true) + if [ -n "$OUTDATED" ]; then + echo "$OUTDATED" + echo "::warning::Some direct dependencies have updates available" + else + echo "All direct dependencies are up to date!" + fi + + - name: Check ecosystem dependencies + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "## Ecosystem Dependencies" + WARNINGS=0 + + check_dep() { + local DEP=$1 REPO=$2 + LOCAL=$(grep "$DEP" go.mod 2>/dev/null | grep -v "^module" | awk '{print $2}') + [ -z "$LOCAL" ] && return 0 + + LATEST=$(gh release view --repo "$REPO" --json tagName -q '.tagName' 2>/dev/null || echo "") + [ -z "$LATEST" ] && { echo "⚠️ $DEP: $LOCAL (cannot verify)"; return 0; } + + if [ "$LOCAL" = "$LATEST" ]; then + echo "✅ $DEP: $LOCAL" + else + echo "❌ $DEP: $LOCAL → $LATEST available" + WARNINGS=$((WARNINGS + 1)) + fi + } + + check_dep "github.com/gogpu/gputypes" "gogpu/gputypes" + + [ $WARNINGS -gt 0 ] && echo "::warning::$WARNINGS ecosystem dep(s) outdated" + exit 0 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8b208c0 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,142 @@ +# GolangCI-Lint v2 Configuration for gogpu/gpucontext - Shared Interfaces +# Documentation: https://golangci-lint.run/docs/configuration/ + +version: "2" + +run: + timeout: 5m + tests: true + +linters: + enable: + # Code quality and complexity + - gocyclo # Check cyclomatic complexity + - gocognit # Check cognitive complexity + - funlen # Check function length + - maintidx # Maintainability index + - cyclop # Check cyclomatic complexity (alternative) + - nestif # Reports deeply nested if statements + + # Bug detection + - govet # Standard Go vet + - staticcheck # Comprehensive static analysis + - errcheck # Check that errors are handled + - errorlint # Check error wrapping + - gosec # Security issues + - nilnil # Check that functions don't return nil both ways + - nilerr # Check nil error returns + - ineffassign # Detect ineffectual assignments + + # Code style and consistency + - misspell # Check for misspelled words + - whitespace # Check for trailing whitespace + - unconvert # Remove unnecessary type conversions + - unparam # Detect unused function parameters + + # Naming conventions + - errname # Check error naming conventions + - revive # Fast, configurable, extensible linter + + # Performance + - prealloc # Find slice declarations that could be preallocated + - makezero # Find slice declarations with non-zero initial length + + # Code practices + - goconst # Find repeated strings that could be constants + - gocritic # Comprehensive code checker + - goprintffuncname # Check printf-like function names + - nolintlint # Check nolint directives are used correctly + - nakedret # Checks for naked returns + + # Additional quality checkers + - dupl # Detect duplicate code + - dogsled # Check for assignments with too many blank identifiers + - durationcheck # Check for two durations multiplied together + + settings: + govet: + enable: + - copylocks + disable: + - fieldalignment + + gocyclo: + min-complexity: 20 + + cyclop: + max-complexity: 20 + + funlen: + lines: 120 + statements: 60 + + gocognit: + min-complexity: 30 + + misspell: + locale: US + + nestif: + min-complexity: 4 + + goconst: + min-occurrences: 4 + ignore-string-values: + - "Unknown" + + revive: + rules: + - name: var-naming + - name: error-return + - name: error-naming + - name: if-return + - name: increment-decrement + - name: var-declaration + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + - name: redefines-builtin-id + + gocritic: + enabled-tags: + - diagnostic + - style + - performance + + disabled-checks: + - commentFormatting + - whyNoLint + - unnamedResult + - commentedOutCode + - octalLiteral + - paramTypeCombine + + settings: + hugeParam: + sizeThreshold: 256 + + exclusions: + rules: + # Test files - allow more flexibility + - path: _test\.go + linters: + - gocyclo + - cyclop + - funlen + - maintidx + - errcheck + - gosec + - goconst + - dupl + - gocognit + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + new: false diff --git a/device_provider.go b/device_provider.go index 57a4156..a2e9366 100644 --- a/device_provider.go +++ b/device_provider.go @@ -47,7 +47,3 @@ type DeviceProvider interface { // Some implementations may not expose the adapter. Adapter() Adapter } - -// DeviceHandle is an alias for DeviceProvider for backward compatibility. -// Deprecated: Use DeviceProvider instead. -type DeviceHandle = DeviceProvider diff --git a/doc.go b/doc.go index a140faf..a922e12 100644 --- a/doc.go +++ b/doc.go @@ -6,6 +6,8 @@ // - DeviceProvider: Interface for providing GPU device and queue // - EventSource: Interface for window/input events (keyboard, mouse) // - TouchEventSource: Interface for touch input (multi-touch, gestures) +// - PointerEventSource: Interface for unified pointer events (W3C Level 3) +// - ScrollEventSource: Interface for detailed scroll events // - Texture: Minimal interface for GPU textures // - TextureDrawer: Interface for drawing textures (2D rendering) // - TextureCreator: Interface for creating textures from pixel data diff --git a/pointer.go b/pointer.go new file mode 100644 index 0000000..493ea32 --- /dev/null +++ b/pointer.go @@ -0,0 +1,356 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import "time" + +// PointerEvent represents a unified pointer event following W3C Pointer Events Level 3. +// +// This event unifies mouse, touch, and pen input into a single abstraction, +// enabling applications to handle all pointing devices uniformly. +// +// W3C Pointer Events Level 3 Specification: +// https://www.w3.org/TR/pointerevents3/ +// +// Key concepts: +// - PointerID: Unique identifier for each active pointer +// - PointerType: Distinguishes mouse, touch, and pen +// - IsPrimary: Identifies the "main" pointer in multi-pointer scenarios +// - Pressure/Tilt: Extended properties for pen/touch input +// +// Example usage: +// +// source.OnPointer(func(ev gpucontext.PointerEvent) { +// switch ev.Type { +// case gpucontext.PointerDown: +// startDrag(ev.PointerID, ev.X, ev.Y) +// case gpucontext.PointerMove: +// if ev.Pressure > 0 { +// updateDrag(ev.PointerID, ev.X, ev.Y, ev.Pressure) +// } +// case gpucontext.PointerUp: +// endDrag(ev.PointerID) +// } +// }) +type PointerEvent struct { + // Type indicates the type of pointer event (down, up, move, etc.). + Type PointerEventType + + // PointerID uniquely identifies the pointer causing this event. + // For mouse, this is typically 1. For touch/pen, each contact has a unique ID. + // The ID remains constant from PointerDown through PointerUp/PointerCancel. + PointerID int + + // X is the horizontal position relative to the window content area. + // Uses logical pixels (CSS pixels equivalent). + X float64 + + // Y is the vertical position relative to the window content area. + // Uses logical pixels (CSS pixels equivalent). + Y float64 + + // Pressure indicates the normalized pressure of the pointer input. + // Range: 0.0 (no pressure) to 1.0 (maximum pressure). + // For devices without pressure support (e.g., mouse), this is: + // - 0.5 when buttons are pressed + // - 0.0 when no buttons are pressed + Pressure float32 + + // TiltX is the plane angle between the Y-Z plane and the plane containing + // the pointer axis and the Y axis, in degrees. + // Range: -90 to 90 degrees. + // Positive values tilt toward the right. + // 0 when the pointer is perpendicular to the surface or not supported. + TiltX float32 + + // TiltY is the plane angle between the X-Z plane and the plane containing + // the pointer axis and the X axis, in degrees. + // Range: -90 to 90 degrees. + // Positive values tilt toward the user. + // 0 when the pointer is perpendicular to the surface or not supported. + TiltY float32 + + // Twist is the clockwise rotation of the pointer around its major axis, + // in degrees. + // Range: 0 to 359 degrees. + // 0 when not supported. + Twist float32 + + // Width is the width of the contact geometry in logical pixels. + // For devices that don't support contact geometry, this is 1. + Width float32 + + // Height is the height of the contact geometry in logical pixels. + // For devices that don't support contact geometry, this is 1. + Height float32 + + // PointerType indicates the type of pointing device. + PointerType PointerType + + // IsPrimary indicates if this is the primary pointer of its type. + // For single-pointer devices (mouse), this is always true. + // For multi-touch, the first finger down is primary. + IsPrimary bool + + // Button indicates which button triggered this event. + // Only meaningful for PointerDown and PointerUp events. + // For PointerMove, this is ButtonNone. + Button Button + + // Buttons is a bitmask of all currently pressed buttons. + // This allows tracking multiple button states during movement. + Buttons Buttons + + // Modifiers contains the keyboard modifier state at event time. + Modifiers Modifiers + + // Timestamp is the event time as duration since an arbitrary reference. + // Useful for calculating velocities and detecting double-clicks. + // Zero if timestamps are not available on the platform. + Timestamp time.Duration +} + +// PointerEventType indicates the type of pointer event. +type PointerEventType uint8 + +const ( + // PointerDown is fired when a pointer becomes active. + // For mouse: button press. + // For touch: contact starts. + // For pen: contact with or hover above the digitizer. + PointerDown PointerEventType = iota + + // PointerUp is fired when a pointer is no longer active. + // For mouse: button release. + // For touch: contact ends. + // For pen: leaves the digitizer detection range. + PointerUp + + // PointerMove is fired when a pointer changes coordinates. + // Also fired when pressure, tilt, or other properties change. + PointerMove + + // PointerEnter is fired when a pointer enters the window bounds. + // Does not bubble in W3C spec, but we deliver it directly. + PointerEnter + + // PointerLeave is fired when a pointer leaves the window bounds. + // Does not bubble in W3C spec, but we deliver it directly. + PointerLeave + + // PointerCancel is fired when the system cancels the pointer. + // This happens when: + // - The browser decides the pointer is unlikely to produce more events + // - A device orientation change occurs + // - The application loses focus during an active pointer + // Always handle cancellation to reset state properly. + PointerCancel +) + +// String returns the event type name for debugging. +func (t PointerEventType) String() string { + switch t { + case PointerDown: + return "PointerDown" + case PointerUp: + return "PointerUp" + case PointerMove: + return "PointerMove" + case PointerEnter: + return "PointerEnter" + case PointerLeave: + return "PointerLeave" + case PointerCancel: + return "PointerCancel" + default: + return "Unknown" + } +} + +// PointerType indicates the type of pointing device. +type PointerType uint8 + +const ( + // PointerTypeMouse indicates a mouse or similar device. + // Includes trackpads when they emulate mouse behavior. + PointerTypeMouse PointerType = iota + + // PointerTypeTouch indicates direct touch input. + // Includes touchscreens and touch-enabled trackpads. + PointerTypeTouch + + // PointerTypePen indicates a stylus or pen input. + // Includes graphics tablets and pen-enabled displays. + PointerTypePen +) + +// String returns the pointer type name for debugging. +func (t PointerType) String() string { + switch t { + case PointerTypeMouse: + return "Mouse" + case PointerTypeTouch: + return "Touch" + case PointerTypePen: + return "Pen" + default: + return "Unknown" + } +} + +// Button identifies which button triggered a pointer event. +// This follows the W3C Pointer Events specification button values. +type Button int8 + +const ( + // ButtonNone indicates no button or no change in button state. + // Used for move events where no button triggered the event. + ButtonNone Button = -1 + + // ButtonLeft is the primary button (usually left mouse button). + ButtonLeft Button = 0 + + // ButtonMiddle is the auxiliary button (usually middle mouse button or wheel click). + ButtonMiddle Button = 1 + + // ButtonRight is the secondary button (usually right mouse button). + ButtonRight Button = 2 + + // ButtonX1 is the first extra button (usually "back" button). + ButtonX1 Button = 3 + + // ButtonX2 is the second extra button (usually "forward" button). + ButtonX2 Button = 4 + + // ButtonEraser is the eraser button on a pen (if available). + ButtonEraser Button = 5 +) + +// String returns the button name for debugging. +func (b Button) String() string { + switch b { + case ButtonNone: + return "None" + case ButtonLeft: + return "Left" + case ButtonMiddle: + return "Middle" + case ButtonRight: + return "Right" + case ButtonX1: + return "X1" + case ButtonX2: + return "X2" + case ButtonEraser: + return "Eraser" + default: + return "Unknown" + } +} + +// Buttons is a bitmask representing currently pressed buttons. +// This allows tracking multiple button states simultaneously. +type Buttons uint8 + +const ( + // ButtonsNone indicates no buttons are pressed. + ButtonsNone Buttons = 0 + + // ButtonsLeft indicates the left button is pressed. + ButtonsLeft Buttons = 1 << 0 + + // ButtonsRight indicates the right button is pressed. + ButtonsRight Buttons = 1 << 1 + + // ButtonsMiddle indicates the middle button is pressed. + ButtonsMiddle Buttons = 1 << 2 + + // ButtonsX1 indicates the X1 (back) button is pressed. + ButtonsX1 Buttons = 1 << 3 + + // ButtonsX2 indicates the X2 (forward) button is pressed. + ButtonsX2 Buttons = 1 << 4 + + // ButtonsEraser indicates the eraser button is pressed. + ButtonsEraser Buttons = 1 << 5 +) + +// HasLeft returns true if the left button is pressed. +func (b Buttons) HasLeft() bool { + return b&ButtonsLeft != 0 +} + +// HasRight returns true if the right button is pressed. +func (b Buttons) HasRight() bool { + return b&ButtonsRight != 0 +} + +// HasMiddle returns true if the middle button is pressed. +func (b Buttons) HasMiddle() bool { + return b&ButtonsMiddle != 0 +} + +// HasX1 returns true if the X1 (back) button is pressed. +func (b Buttons) HasX1() bool { + return b&ButtonsX1 != 0 +} + +// HasX2 returns true if the X2 (forward) button is pressed. +func (b Buttons) HasX2() bool { + return b&ButtonsX2 != 0 +} + +// HasEraser returns true if the eraser button is pressed. +func (b Buttons) HasEraser() bool { + return b&ButtonsEraser != 0 +} + +// Count returns the number of pressed buttons. +func (b Buttons) Count() int { + count := 0 + for v := b; v != 0; v &= v - 1 { + count++ + } + return count +} + +// PointerEventSource extends EventSource with unified pointer event capabilities. +// +// This interface provides W3C Pointer Events Level 3 compliant pointer input, +// unifying mouse, touch, and pen input into a single event stream. +// +// Implementation note: Rather than adding to EventSource directly, +// we use a separate interface to maintain backward compatibility +// and allow type assertion: +// +// if pes, ok := eventSource.(gpucontext.PointerEventSource); ok { +// pes.OnPointer(handlePointerEvent) +// } +// +// For applications that need only unified pointer events: +// +// pes.OnPointer(func(ev gpucontext.PointerEvent) { +// // Handle all pointer types uniformly +// }) +type PointerEventSource interface { + // OnPointer registers a callback for pointer events. + // The callback receives a PointerEvent containing all pointer information. + // + // Callback threading: Called on the main/UI thread. + // Callbacks should be fast and non-blocking. + // + // Pointer events are delivered in order: + // PointerEnter -> PointerDown -> PointerMove* -> PointerUp/PointerCancel -> PointerLeave + OnPointer(fn func(PointerEvent)) +} + +// NullPointerEventSource implements PointerEventSource by ignoring all registrations. +// Useful for platforms or configurations where pointer input is not available. +type NullPointerEventSource struct{} + +// OnPointer does nothing. +func (NullPointerEventSource) OnPointer(func(PointerEvent)) {} + +// Ensure NullPointerEventSource implements PointerEventSource. +var _ PointerEventSource = NullPointerEventSource{} diff --git a/pointer_test.go b/pointer_test.go new file mode 100644 index 0000000..4c6dfea --- /dev/null +++ b/pointer_test.go @@ -0,0 +1,387 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import ( + "testing" + "time" +) + +func TestPointerEventType_String(t *testing.T) { + tests := []struct { + eventType PointerEventType + want string + }{ + {PointerDown, "PointerDown"}, + {PointerUp, "PointerUp"}, + {PointerMove, "PointerMove"}, + {PointerEnter, "PointerEnter"}, + {PointerLeave, "PointerLeave"}, + {PointerCancel, "PointerCancel"}, + {PointerEventType(99), "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.eventType.String(); got != tt.want { + t.Errorf("PointerEventType(%d).String() = %q, want %q", tt.eventType, got, tt.want) + } + }) + } +} + +func TestPointerType_String(t *testing.T) { + tests := []struct { + pointerType PointerType + want string + }{ + {PointerTypeMouse, "Mouse"}, + {PointerTypeTouch, "Touch"}, + {PointerTypePen, "Pen"}, + {PointerType(99), "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.pointerType.String(); got != tt.want { + t.Errorf("PointerType(%d).String() = %q, want %q", tt.pointerType, got, tt.want) + } + }) + } +} + +func TestButton_String(t *testing.T) { + tests := []struct { + button Button + want string + }{ + {ButtonNone, "None"}, + {ButtonLeft, "Left"}, + {ButtonMiddle, "Middle"}, + {ButtonRight, "Right"}, + {ButtonX1, "X1"}, + {ButtonX2, "X2"}, + {ButtonEraser, "Eraser"}, + {Button(99), "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.button.String(); got != tt.want { + t.Errorf("Button(%d).String() = %q, want %q", tt.button, got, tt.want) + } + }) + } +} + +func TestButtonConstants(t *testing.T) { + // Verify W3C-compliant button values + if ButtonNone != -1 { + t.Errorf("ButtonNone = %d, want -1", ButtonNone) + } + if ButtonLeft != 0 { + t.Errorf("ButtonLeft = %d, want 0", ButtonLeft) + } + if ButtonMiddle != 1 { + t.Errorf("ButtonMiddle = %d, want 1", ButtonMiddle) + } + if ButtonRight != 2 { + t.Errorf("ButtonRight = %d, want 2", ButtonRight) + } + if ButtonX1 != 3 { + t.Errorf("ButtonX1 = %d, want 3", ButtonX1) + } + if ButtonX2 != 4 { + t.Errorf("ButtonX2 = %d, want 4", ButtonX2) + } +} + +func TestButtons_HasMethods(t *testing.T) { + tests := []struct { + name string + buttons Buttons + left bool + right bool + middle bool + x1 bool + x2 bool + eraser bool + }{ + {"none", ButtonsNone, false, false, false, false, false, false}, + {"left only", ButtonsLeft, true, false, false, false, false, false}, + {"right only", ButtonsRight, false, true, false, false, false, false}, + {"middle only", ButtonsMiddle, false, false, true, false, false, false}, + {"x1 only", ButtonsX1, false, false, false, true, false, false}, + {"x2 only", ButtonsX2, false, false, false, false, true, false}, + {"eraser only", ButtonsEraser, false, false, false, false, false, true}, + {"left and right", ButtonsLeft | ButtonsRight, true, true, false, false, false, false}, + {"all buttons", ButtonsLeft | ButtonsRight | ButtonsMiddle | ButtonsX1 | ButtonsX2 | ButtonsEraser, + true, true, true, true, true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.buttons.HasLeft(); got != tt.left { + t.Errorf("Buttons.HasLeft() = %v, want %v", got, tt.left) + } + if got := tt.buttons.HasRight(); got != tt.right { + t.Errorf("Buttons.HasRight() = %v, want %v", got, tt.right) + } + if got := tt.buttons.HasMiddle(); got != tt.middle { + t.Errorf("Buttons.HasMiddle() = %v, want %v", got, tt.middle) + } + if got := tt.buttons.HasX1(); got != tt.x1 { + t.Errorf("Buttons.HasX1() = %v, want %v", got, tt.x1) + } + if got := tt.buttons.HasX2(); got != tt.x2 { + t.Errorf("Buttons.HasX2() = %v, want %v", got, tt.x2) + } + if got := tt.buttons.HasEraser(); got != tt.eraser { + t.Errorf("Buttons.HasEraser() = %v, want %v", got, tt.eraser) + } + }) + } +} + +func TestButtons_Count(t *testing.T) { + tests := []struct { + buttons Buttons + count int + }{ + {ButtonsNone, 0}, + {ButtonsLeft, 1}, + {ButtonsLeft | ButtonsRight, 2}, + {ButtonsLeft | ButtonsRight | ButtonsMiddle, 3}, + {ButtonsLeft | ButtonsRight | ButtonsMiddle | ButtonsX1 | ButtonsX2 | ButtonsEraser, 6}, + } + + for _, tt := range tests { + if got := tt.buttons.Count(); got != tt.count { + t.Errorf("Buttons(%d).Count() = %d, want %d", tt.buttons, got, tt.count) + } + } +} + +func TestButtonsConstants(t *testing.T) { + // Verify button bitmask values + if ButtonsNone != 0 { + t.Errorf("ButtonsNone = %d, want 0", ButtonsNone) + } + if ButtonsLeft != 1 { + t.Errorf("ButtonsLeft = %d, want 1", ButtonsLeft) + } + if ButtonsRight != 2 { + t.Errorf("ButtonsRight = %d, want 2", ButtonsRight) + } + if ButtonsMiddle != 4 { + t.Errorf("ButtonsMiddle = %d, want 4", ButtonsMiddle) + } + if ButtonsX1 != 8 { + t.Errorf("ButtonsX1 = %d, want 8", ButtonsX1) + } + if ButtonsX2 != 16 { + t.Errorf("ButtonsX2 = %d, want 16", ButtonsX2) + } + if ButtonsEraser != 32 { + t.Errorf("ButtonsEraser = %d, want 32", ButtonsEraser) + } +} + +func TestPointerEvent_ZeroValue(t *testing.T) { + var ev PointerEvent + + if ev.Type != PointerDown { + t.Errorf("Zero value Type = %v, want PointerDown", ev.Type) + } + if ev.PointerID != 0 { + t.Errorf("Zero value PointerID = %d, want 0", ev.PointerID) + } + if ev.X != 0 { + t.Errorf("Zero value X = %f, want 0", ev.X) + } + if ev.Y != 0 { + t.Errorf("Zero value Y = %f, want 0", ev.Y) + } + if ev.Pressure != 0 { + t.Errorf("Zero value Pressure = %f, want 0", ev.Pressure) + } + if ev.PointerType != PointerTypeMouse { + t.Errorf("Zero value PointerType = %v, want PointerTypeMouse", ev.PointerType) + } + if ev.IsPrimary { + t.Error("Zero value IsPrimary should be false") + } +} + +func TestPointerEvent_FullConstruction(t *testing.T) { + ev := PointerEvent{ + Type: PointerMove, + PointerID: 42, + X: 100.5, + Y: 200.5, + Pressure: 0.75, + TiltX: 15.0, + TiltY: -10.0, + Twist: 45.0, + Width: 20.0, + Height: 25.0, + PointerType: PointerTypePen, + IsPrimary: true, + Button: ButtonNone, + Buttons: ButtonsLeft | ButtonsMiddle, + Modifiers: ModShift | ModControl, + Timestamp: time.Millisecond * 12345, + } + + if ev.Type != PointerMove { + t.Errorf("Type = %v, want PointerMove", ev.Type) + } + if ev.PointerID != 42 { + t.Errorf("PointerID = %d, want 42", ev.PointerID) + } + if ev.X != 100.5 { + t.Errorf("X = %f, want 100.5", ev.X) + } + if ev.Y != 200.5 { + t.Errorf("Y = %f, want 200.5", ev.Y) + } + if ev.Pressure != 0.75 { + t.Errorf("Pressure = %f, want 0.75", ev.Pressure) + } + if ev.TiltX != 15.0 { + t.Errorf("TiltX = %f, want 15.0", ev.TiltX) + } + if ev.TiltY != -10.0 { + t.Errorf("TiltY = %f, want -10.0", ev.TiltY) + } + if ev.Twist != 45.0 { + t.Errorf("Twist = %f, want 45.0", ev.Twist) + } + if ev.Width != 20.0 { + t.Errorf("Width = %f, want 20.0", ev.Width) + } + if ev.Height != 25.0 { + t.Errorf("Height = %f, want 25.0", ev.Height) + } + if ev.PointerType != PointerTypePen { + t.Errorf("PointerType = %v, want PointerTypePen", ev.PointerType) + } + if !ev.IsPrimary { + t.Error("IsPrimary should be true") + } + if ev.Button != ButtonNone { + t.Errorf("Button = %v, want ButtonNone", ev.Button) + } + if !ev.Buttons.HasLeft() { + t.Error("Buttons should have left") + } + if !ev.Buttons.HasMiddle() { + t.Error("Buttons should have middle") + } + if !ev.Modifiers.HasShift() { + t.Error("Modifiers should have shift") + } + if !ev.Modifiers.HasControl() { + t.Error("Modifiers should have control") + } + if ev.Timestamp != time.Millisecond*12345 { + t.Errorf("Timestamp = %v, want %v", ev.Timestamp, time.Millisecond*12345) + } +} + +func TestPointerEventType_Values(t *testing.T) { + // Verify event type constants are sequential + if PointerDown != 0 { + t.Errorf("PointerDown = %d, want 0", PointerDown) + } + if PointerUp != 1 { + t.Errorf("PointerUp = %d, want 1", PointerUp) + } + if PointerMove != 2 { + t.Errorf("PointerMove = %d, want 2", PointerMove) + } + if PointerEnter != 3 { + t.Errorf("PointerEnter = %d, want 3", PointerEnter) + } + if PointerLeave != 4 { + t.Errorf("PointerLeave = %d, want 4", PointerLeave) + } + if PointerCancel != 5 { + t.Errorf("PointerCancel = %d, want 5", PointerCancel) + } +} + +func TestPointerType_Values(t *testing.T) { + // Verify pointer type constants are sequential + if PointerTypeMouse != 0 { + t.Errorf("PointerTypeMouse = %d, want 0", PointerTypeMouse) + } + if PointerTypeTouch != 1 { + t.Errorf("PointerTypeTouch = %d, want 1", PointerTypeTouch) + } + if PointerTypePen != 2 { + t.Errorf("PointerTypePen = %d, want 2", PointerTypePen) + } +} + +func TestNullPointerEventSource(t *testing.T) { + // NullPointerEventSource should implement PointerEventSource + var pes PointerEventSource = NullPointerEventSource{} + + // Method should be callable without panic + pes.OnPointer(func(PointerEvent) {}) +} + +// mockPointerEventSource is used to verify PointerEventSource interface. +type mockPointerEventSource struct { + handler func(PointerEvent) +} + +func (m *mockPointerEventSource) OnPointer(fn func(PointerEvent)) { + m.handler = fn +} + +// Ensure mockPointerEventSource implements PointerEventSource. +var _ PointerEventSource = &mockPointerEventSource{} + +func TestPointerEventSource_Interface(t *testing.T) { + // Verify PointerEventSource can be used through the interface + mock := &mockPointerEventSource{} + var source PointerEventSource = mock + + var received *PointerEvent + source.OnPointer(func(ev PointerEvent) { + received = &ev + }) + + // Simulate event dispatch + testEvent := PointerEvent{ + Type: PointerDown, + PointerID: 1, + X: 100, + Y: 200, + PointerType: PointerTypeMouse, + IsPrimary: true, + Button: ButtonLeft, + Buttons: ButtonsLeft, + } + + mock.handler(testEvent) + + if received == nil { + t.Fatal("Handler was not called") + } + if received.Type != PointerDown { + t.Errorf("received.Type = %v, want PointerDown", received.Type) + } + if received.PointerID != 1 { + t.Errorf("received.PointerID = %d, want 1", received.PointerID) + } + if received.X != 100 { + t.Errorf("received.X = %f, want 100", received.X) + } + if received.Y != 200 { + t.Errorf("received.Y = %f, want 200", received.Y) + } +} diff --git a/scroll.go b/scroll.go new file mode 100644 index 0000000..b32058a --- /dev/null +++ b/scroll.go @@ -0,0 +1,129 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import "time" + +// ScrollEvent represents a scroll wheel or touchpad scroll event. +// +// This event is separate from PointerEvent because scroll events have +// different semantics: +// - They don't have a persistent pointer ID +// - They provide delta values rather than absolute positions +// - They may have different units (lines, pages, pixels) +// +// For touchpad gestures, consider using GestureEvent (if available) +// for pinch-to-zoom and other multi-finger gestures. +// +// Example usage: +// +// source.OnScroll(func(ev gpucontext.ScrollEvent) { +// scrollY += ev.DeltaY * scrollSpeed +// if ev.Modifiers.HasControl() { +// // Ctrl+scroll for zoom +// zoom *= 1.0 + ev.DeltaY * 0.1 +// } +// }) +type ScrollEvent struct { + // X is the pointer horizontal position at the time of scrolling. + // Uses logical pixels relative to the window content area. + X float64 + + // Y is the pointer vertical position at the time of scrolling. + // Uses logical pixels relative to the window content area. + Y float64 + + // DeltaX is the horizontal scroll amount. + // Positive values scroll content to the right (or indicate rightward intent). + // The unit depends on DeltaMode. + DeltaX float64 + + // DeltaY is the vertical scroll amount. + // Positive values scroll content down (or indicate downward intent). + // The unit depends on DeltaMode. + DeltaY float64 + + // DeltaMode indicates the unit of the delta values. + DeltaMode ScrollDeltaMode + + // Modifiers contains the keyboard modifier state at event time. + // Commonly used for Ctrl+scroll zoom behavior. + Modifiers Modifiers + + // Timestamp is the event time as duration since an arbitrary reference. + // Useful for smooth scrolling animations. + // Zero if timestamps are not available on the platform. + Timestamp time.Duration +} + +// ScrollDeltaMode indicates the unit of scroll delta values. +type ScrollDeltaMode uint8 + +const ( + // ScrollDeltaPixel indicates delta values are in logical pixels. + // This is typical for touchpad scrolling. + ScrollDeltaPixel ScrollDeltaMode = iota + + // ScrollDeltaLine indicates delta values are in lines. + // This is typical for traditional mouse wheel scrolling. + // Applications should convert to pixels using their line height. + ScrollDeltaLine + + // ScrollDeltaPage indicates delta values are in pages. + // This is rare but may occur for Page Up/Down emulation. + // Applications should convert to pixels using their viewport height. + ScrollDeltaPage +) + +// String returns the delta mode name for debugging. +func (m ScrollDeltaMode) String() string { + switch m { + case ScrollDeltaPixel: + return "Pixel" + case ScrollDeltaLine: + return "Line" + case ScrollDeltaPage: + return "Page" + default: + return "Unknown" + } +} + +// ScrollEventSource extends EventSource with scroll event capabilities. +// +// This interface provides detailed scroll events with position, delta mode, +// and timing information beyond what the basic EventSource.OnScroll provides. +// +// For basic scroll handling, EventSource.OnScroll(dx, dy) is sufficient. +// Use ScrollEventSource when you need: +// - Pointer position at scroll time +// - Delta mode (pixels vs lines vs pages) +// - Timestamps for smooth scrolling +// +// Type assertion pattern: +// +// if ses, ok := eventSource.(gpucontext.ScrollEventSource); ok { +// ses.OnScrollEvent(handleScrollEvent) +// } else { +// // Fall back to basic scroll handler +// eventSource.OnScroll(handleBasicScroll) +// } +type ScrollEventSource interface { + // OnScrollEvent registers a callback for detailed scroll events. + // The callback receives a ScrollEvent containing all scroll information. + // + // Callback threading: Called on the main/UI thread. + // Callbacks should be fast and non-blocking. + OnScrollEvent(fn func(ScrollEvent)) +} + +// NullScrollEventSource implements ScrollEventSource by ignoring all registrations. +// Useful for platforms or configurations where scroll input is not available. +type NullScrollEventSource struct{} + +// OnScrollEvent does nothing. +func (NullScrollEventSource) OnScrollEvent(func(ScrollEvent)) {} + +// Ensure NullScrollEventSource implements ScrollEventSource. +var _ ScrollEventSource = NullScrollEventSource{} diff --git a/scroll_test.go b/scroll_test.go new file mode 100644 index 0000000..2209815 --- /dev/null +++ b/scroll_test.go @@ -0,0 +1,233 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import ( + "testing" + "time" +) + +func TestScrollDeltaMode_String(t *testing.T) { + tests := []struct { + mode ScrollDeltaMode + want string + }{ + {ScrollDeltaPixel, "Pixel"}, + {ScrollDeltaLine, "Line"}, + {ScrollDeltaPage, "Page"}, + {ScrollDeltaMode(99), "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.mode.String(); got != tt.want { + t.Errorf("ScrollDeltaMode(%d).String() = %q, want %q", tt.mode, got, tt.want) + } + }) + } +} + +func TestScrollDeltaMode_Values(t *testing.T) { + // Verify delta mode constants are sequential + if ScrollDeltaPixel != 0 { + t.Errorf("ScrollDeltaPixel = %d, want 0", ScrollDeltaPixel) + } + if ScrollDeltaLine != 1 { + t.Errorf("ScrollDeltaLine = %d, want 1", ScrollDeltaLine) + } + if ScrollDeltaPage != 2 { + t.Errorf("ScrollDeltaPage = %d, want 2", ScrollDeltaPage) + } +} + +func TestScrollEvent_ZeroValue(t *testing.T) { + var ev ScrollEvent + + if ev.X != 0 { + t.Errorf("Zero value X = %f, want 0", ev.X) + } + if ev.Y != 0 { + t.Errorf("Zero value Y = %f, want 0", ev.Y) + } + if ev.DeltaX != 0 { + t.Errorf("Zero value DeltaX = %f, want 0", ev.DeltaX) + } + if ev.DeltaY != 0 { + t.Errorf("Zero value DeltaY = %f, want 0", ev.DeltaY) + } + if ev.DeltaMode != ScrollDeltaPixel { + t.Errorf("Zero value DeltaMode = %v, want ScrollDeltaPixel", ev.DeltaMode) + } + if ev.Modifiers != 0 { + t.Errorf("Zero value Modifiers = %d, want 0", ev.Modifiers) + } + if ev.Timestamp != 0 { + t.Errorf("Zero value Timestamp = %v, want 0", ev.Timestamp) + } +} + +func TestScrollEvent_FullConstruction(t *testing.T) { + ev := ScrollEvent{ + X: 100.5, + Y: 200.5, + DeltaX: 10.0, + DeltaY: -20.0, + DeltaMode: ScrollDeltaLine, + Modifiers: ModControl, + Timestamp: time.Millisecond * 5000, + } + + if ev.X != 100.5 { + t.Errorf("X = %f, want 100.5", ev.X) + } + if ev.Y != 200.5 { + t.Errorf("Y = %f, want 200.5", ev.Y) + } + if ev.DeltaX != 10.0 { + t.Errorf("DeltaX = %f, want 10.0", ev.DeltaX) + } + if ev.DeltaY != -20.0 { + t.Errorf("DeltaY = %f, want -20.0", ev.DeltaY) + } + if ev.DeltaMode != ScrollDeltaLine { + t.Errorf("DeltaMode = %v, want ScrollDeltaLine", ev.DeltaMode) + } + if !ev.Modifiers.HasControl() { + t.Error("Modifiers should have control") + } + if ev.Timestamp != time.Millisecond*5000 { + t.Errorf("Timestamp = %v, want %v", ev.Timestamp, time.Millisecond*5000) + } +} + +func TestScrollEvent_VerticalScroll(t *testing.T) { + // Typical vertical scroll event from mouse wheel + ev := ScrollEvent{ + X: 400, + Y: 300, + DeltaX: 0, + DeltaY: 3, // Scroll down 3 lines + DeltaMode: ScrollDeltaLine, + } + + if ev.DeltaX != 0 { + t.Errorf("DeltaX = %f, want 0", ev.DeltaX) + } + if ev.DeltaY != 3 { + t.Errorf("DeltaY = %f, want 3", ev.DeltaY) + } + if ev.DeltaMode != ScrollDeltaLine { + t.Errorf("DeltaMode = %v, want ScrollDeltaLine", ev.DeltaMode) + } +} + +func TestScrollEvent_HorizontalScroll(t *testing.T) { + // Horizontal scroll from trackpad + ev := ScrollEvent{ + X: 400, + Y: 300, + DeltaX: 50, // Scroll right 50 pixels + DeltaY: 0, + DeltaMode: ScrollDeltaPixel, + } + + if ev.DeltaX != 50 { + t.Errorf("DeltaX = %f, want 50", ev.DeltaX) + } + if ev.DeltaY != 0 { + t.Errorf("DeltaY = %f, want 0", ev.DeltaY) + } + if ev.DeltaMode != ScrollDeltaPixel { + t.Errorf("DeltaMode = %v, want ScrollDeltaPixel", ev.DeltaMode) + } +} + +func TestScrollEvent_PageScroll(t *testing.T) { + // Page scroll (rare, but possible) + ev := ScrollEvent{ + X: 400, + Y: 300, + DeltaX: 0, + DeltaY: 1, // Scroll down 1 page + DeltaMode: ScrollDeltaPage, + } + + if ev.DeltaY != 1 { + t.Errorf("DeltaY = %f, want 1", ev.DeltaY) + } + if ev.DeltaMode != ScrollDeltaPage { + t.Errorf("DeltaMode = %v, want ScrollDeltaPage", ev.DeltaMode) + } +} + +func TestScrollEvent_CtrlScroll(t *testing.T) { + // Ctrl+scroll typically means zoom + ev := ScrollEvent{ + X: 400, + Y: 300, + DeltaX: 0, + DeltaY: -1, + DeltaMode: ScrollDeltaLine, + Modifiers: ModControl, + } + + if !ev.Modifiers.HasControl() { + t.Error("Should detect Ctrl modifier for zoom detection") + } +} + +func TestNullScrollEventSource(t *testing.T) { + // NullScrollEventSource should implement ScrollEventSource + var ses ScrollEventSource = NullScrollEventSource{} + + // Method should be callable without panic + ses.OnScrollEvent(func(ScrollEvent) {}) +} + +// mockScrollEventSource is used to verify ScrollEventSource interface. +type mockScrollEventSource struct { + handler func(ScrollEvent) +} + +func (m *mockScrollEventSource) OnScrollEvent(fn func(ScrollEvent)) { + m.handler = fn +} + +// Ensure mockScrollEventSource implements ScrollEventSource. +var _ ScrollEventSource = &mockScrollEventSource{} + +func TestScrollEventSource_Interface(t *testing.T) { + // Verify ScrollEventSource can be used through the interface + mock := &mockScrollEventSource{} + var source ScrollEventSource = mock + + var received *ScrollEvent + source.OnScrollEvent(func(ev ScrollEvent) { + received = &ev + }) + + // Simulate event dispatch + testEvent := ScrollEvent{ + X: 100, + Y: 200, + DeltaX: 0, + DeltaY: -120, // Scroll up 120 pixels + DeltaMode: ScrollDeltaPixel, + } + + mock.handler(testEvent) + + if received == nil { + t.Fatal("Handler was not called") + } + if received.X != 100 { + t.Errorf("received.X = %f, want 100", received.X) + } + if received.Y != 200 { + t.Errorf("received.Y = %f, want 200", received.Y) + } + if received.DeltaY != -120 { + t.Errorf("received.DeltaY = %f, want -120", received.DeltaY) + } +} diff --git a/touch.go b/touch.go index 8bbc4cb..48c41d8 100644 --- a/touch.go +++ b/touch.go @@ -6,7 +6,7 @@ package gpucontext import "time" // TouchID uniquely identifies a touch point within a touch session. -// The ID remains constant from TouchBegan through TouchEnded/TouchCancelled. +// The ID remains constant from TouchBegan through TouchEnded/TouchCanceled. // IDs may be reused after a touch ends. // // Design note: Using int for compatibility with both int32 (Android) and int64 (iOS). @@ -16,7 +16,7 @@ type TouchID int // TouchPhase represents the lifecycle stage of a touch point. // These phases align with platform conventions: // - Android: ACTION_DOWN/MOVE/UP/CANCEL -// - iOS: touchesBegan/Moved/Ended/Cancelled +// - iOS: touchesBegan/Moved/Ended/Canceled // - W3C: touchstart/move/end/cancel type TouchPhase uint8 @@ -33,13 +33,13 @@ const ( // Sent once per touch point at the end of interaction. TouchEnded - // TouchCancelled indicates the system interrupted the touch. + // TouchCanceled indicates the system interrupted the touch. // This can happen when: // - The app loses focus // - A system gesture is recognized (e.g., swipe to home) // - The touch hardware reports an error // Always handle cancellation to reset UI state properly. - TouchCancelled + TouchCanceled ) // String returns the phase name for debugging. @@ -51,8 +51,8 @@ func (p TouchPhase) String() string { return "Moved" case TouchEnded: return "Ended" - case TouchCancelled: - return "Cancelled" + case TouchCanceled: + return "Canceled" default: return "Unknown" } @@ -103,7 +103,7 @@ type TouchPoint struct { // - TouchBegan: Changed contains new touches, All contains all active touches // - TouchMoved: Changed contains moved touches, All contains all active touches // - TouchEnded: Changed contains lifted touches, All contains remaining touches -// - TouchCancelled: Changed contains cancelled touches, All may be empty +// - TouchCanceled: Changed contains canceled touches, All may be empty // // Example multi-touch pinch gesture processing: // @@ -124,12 +124,12 @@ type TouchEvent struct { // For TouchBegan: newly added touches // For TouchMoved: touches that moved // For TouchEnded: touches that were lifted - // For TouchCancelled: touches that were interrupted + // For TouchCanceled: touches that were interrupted Changed []TouchPoint // All contains all currently active touch points. // Useful for multi-touch gestures that need to track all contacts. - // For TouchEnded/TouchCancelled, this excludes the Changed touches. + // For TouchEnded/TouchCanceled, this excludes the Changed touches. All []TouchPoint // Modifiers contains keyboard modifier state at the time of the event. @@ -160,7 +160,7 @@ type TouchEventSource interface { // Callback threading: Called on the main/UI thread. // Callbacks should be fast and non-blocking. // - // Touch events are delivered in order: Began -> Moved* -> Ended/Cancelled + // Touch events are delivered in order: Began -> Moved* -> Ended/Canceled // Multi-touch events for simultaneous contacts are coalesced into single events. OnTouch(fn func(TouchEvent)) }