From 43faa3520fd5860442f1e96835c3a7bbfad0ccf7 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 16 May 2026 23:56:12 +0300 Subject: [PATCH] feat: ScrollPhase + IsMomentum in ScrollEvent (ADR-032) Add ScrollPhase type (None/Began/Changed/Ended/Canceled) and IsMomentum bool to ScrollEvent. Enables apps to distinguish active scroll from macOS trackpad momentum. Zero-value backward compatible. 6 new tests. --- CHANGELOG.md | 6 +++ scroll.go | 55 +++++++++++++++++++++++++++ scroll_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a2123..e16154b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scroll.go b/scroll.go index b32058a..a20403c 100644 --- a/scroll.go +++ b/scroll.go @@ -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 @@ -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. diff --git a/scroll_test.go b/scroll_test.go index 2209815..8530acc 100644 --- a/scroll_test.go +++ b/scroll_test.go @@ -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 @@ -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) { @@ -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{}