Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **ScrollPhase + IsMomentum on ScrollEvent** (FEAT-INPUT-021) — `ScrollPhase` enum (`None`, `Began`, `Changed`, `Ended`, `Canceled`) and `IsMomentum bool` field on `ScrollEvent`. Enables apps to distinguish active trackpad gestures from momentum/inertial scroll. On macOS: maps NSEvent.phase and NSEvent.momentumPhase. On Wayland: maps axis_stop. Zero value preserves backward compatibility (Phase=None, IsMomentum=false).

## [0.18.0] - 2026-05-09

### Added
Expand Down
55 changes: 55 additions & 0 deletions scroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ package gpucontext

import "time"

// ScrollPhase indicates the phase of a scroll gesture.
// On macOS trackpad: reflects active touch or inertial momentum.
// On Wayland: reflects axis source start/stop.
// For discrete mouse wheel or platforms without gesture phases: always ScrollPhaseNone.
type ScrollPhase uint8

const (
// ScrollPhaseNone indicates a discrete scroll event (e.g. mouse wheel click).
// No gesture phase tracking is available.
ScrollPhaseNone ScrollPhase = iota

// ScrollPhaseBegan indicates the start of a scroll gesture or momentum phase.
ScrollPhaseBegan

// ScrollPhaseChanged indicates an ongoing scroll gesture or momentum phase.
ScrollPhaseChanged

// ScrollPhaseEnded indicates the end of a scroll gesture or momentum phase.
ScrollPhaseEnded

// ScrollPhaseCanceled indicates the scroll gesture was interrupted.
ScrollPhaseCanceled
)

// String returns the name of the scroll phase.
func (p ScrollPhase) String() string {
switch p {
case ScrollPhaseNone:
return stringNone
case ScrollPhaseBegan:
return "Began"
case ScrollPhaseChanged:
return "Changed"
case ScrollPhaseEnded:
return "Ended"
case ScrollPhaseCanceled:
return "Canceled"
default:
return "Unknown"
}
}

// ScrollEvent represents a scroll wheel or touchpad scroll event.
//
// This event is separate from PointerEvent because scroll events have
Expand Down Expand Up @@ -55,6 +97,19 @@ type ScrollEvent struct {
// Useful for smooth scrolling animations.
// Zero if timestamps are not available on the platform.
Timestamp time.Duration

// Phase indicates the scroll gesture phase.
// On macOS: reflects NSEvent.phase (active touch gesture).
// On Wayland: derived from axis_source and axis_stop events.
// For discrete mouse wheel: always ScrollPhaseNone.
Phase ScrollPhase

// IsMomentum indicates this is an inertial/momentum scroll event.
// On macOS trackpad: true when NSEvent.momentumPhase is active
// (user lifted fingers but scroll continues with deceleration).
// Applications can filter momentum events to stop coasting on cursor exit.
// On platforms without momentum scrolling: always false.
IsMomentum bool
}

// ScrollDeltaMode indicates the unit of scroll delta values.
Expand Down
101 changes: 101 additions & 0 deletions scroll_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,47 @@ import (
"time"
)

func TestScrollPhase_Values(t *testing.T) {
// Verify scroll phase constants are sequential starting from 0
if ScrollPhaseNone != 0 {
t.Errorf("ScrollPhaseNone = %d, want 0", ScrollPhaseNone)
}
if ScrollPhaseBegan != 1 {
t.Errorf("ScrollPhaseBegan = %d, want 1", ScrollPhaseBegan)
}
if ScrollPhaseChanged != 2 {
t.Errorf("ScrollPhaseChanged = %d, want 2", ScrollPhaseChanged)
}
if ScrollPhaseEnded != 3 {
t.Errorf("ScrollPhaseEnded = %d, want 3", ScrollPhaseEnded)
}
if ScrollPhaseCanceled != 4 {
t.Errorf("ScrollPhaseCanceled = %d, want 4", ScrollPhaseCanceled)
}
}

func TestScrollPhase_String(t *testing.T) {
tests := []struct {
phase ScrollPhase
want string
}{
{ScrollPhaseNone, "None"},
{ScrollPhaseBegan, "Began"},
{ScrollPhaseChanged, "Changed"},
{ScrollPhaseEnded, "Ended"},
{ScrollPhaseCanceled, "Canceled"},
{ScrollPhase(99), "Unknown"},
}

for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
if got := tt.phase.String(); got != tt.want {
t.Errorf("ScrollPhase(%d).String() = %q, want %q", tt.phase, got, tt.want)
}
})
}
}

func TestScrollDeltaMode_String(t *testing.T) {
tests := []struct {
mode ScrollDeltaMode
Expand Down Expand Up @@ -65,6 +106,12 @@ func TestScrollEvent_ZeroValue(t *testing.T) {
if ev.Timestamp != 0 {
t.Errorf("Zero value Timestamp = %v, want 0", ev.Timestamp)
}
if ev.Phase != ScrollPhaseNone {
t.Errorf("Zero value Phase = %v, want ScrollPhaseNone", ev.Phase)
}
if ev.IsMomentum {
t.Error("Zero value IsMomentum = true, want false")
}
}

func TestScrollEvent_FullConstruction(t *testing.T) {
Expand Down Expand Up @@ -177,6 +224,60 @@ func TestScrollEvent_CtrlScroll(t *testing.T) {
}
}

func TestScrollEvent_WithPhaseAndMomentum(t *testing.T) {
// macOS trackpad momentum event
ev := ScrollEvent{
X: 400,
Y: 300,
DeltaX: 0,
DeltaY: -5.5,
DeltaMode: ScrollDeltaPixel,
Phase: ScrollPhaseChanged,
IsMomentum: true,
}

if ev.Phase != ScrollPhaseChanged {
t.Errorf("Phase = %v, want ScrollPhaseChanged", ev.Phase)
}
if !ev.IsMomentum {
t.Error("IsMomentum = false, want true")
}
if ev.DeltaY != -5.5 {
t.Errorf("DeltaY = %f, want -5.5", ev.DeltaY)
}
}

func TestScrollEvent_GestureLifecycle(t *testing.T) {
// Simulate a complete macOS trackpad gesture lifecycle
phases := []struct {
phase ScrollPhase
isMomentum bool
desc string
}{
{ScrollPhaseBegan, false, "finger touch"},
{ScrollPhaseChanged, false, "finger drag"},
{ScrollPhaseChanged, false, "finger drag"},
{ScrollPhaseEnded, false, "finger lift"},
{ScrollPhaseBegan, true, "momentum start"},
{ScrollPhaseChanged, true, "momentum coast"},
{ScrollPhaseChanged, true, "momentum coast"},
{ScrollPhaseEnded, true, "momentum stop"},
}

for i, tt := range phases {
ev := ScrollEvent{
Phase: tt.phase,
IsMomentum: tt.isMomentum,
}
if ev.Phase != tt.phase {
t.Errorf("step %d (%s): Phase = %v, want %v", i, tt.desc, ev.Phase, tt.phase)
}
if ev.IsMomentum != tt.isMomentum {
t.Errorf("step %d (%s): IsMomentum = %v, want %v", i, tt.desc, ev.IsMomentum, tt.isMomentum)
}
}
}

func TestNullScrollEventSource(t *testing.T) {
// NullScrollEventSource should implement ScrollEventSource
var ses ScrollEventSource = NullScrollEventSource{}
Expand Down
Loading