detached 9447c77

feat(core): forward other touchpad gestures to the pointer-focus window

commit
9447c77e22344f19ae02e0a20d43393ebd7b43b9
author
brice · · 2026-10-08 09:32 UTC
committer
brice · · 2026-10-08 09:59 UTC
parent
dc119ed
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +429 -9
M internal/core/client.go +1 -1
@@ -114,7 +114,7 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
114114 c.releaseSlots()
115115 if c.pointer == v.ID {
116116 c.pointer = 0
117- if err := c.command(ctx, ports.PointerFocus{}); err != nil {
117+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
118118 return err
119119 }
120120 }
@@ -114,7 +114,7 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
114 c.releaseSlots()114 c.releaseSlots()
115 if c.pointer == v.ID {115 if c.pointer == v.ID {
116 c.pointer = 0116 c.pointer = 0
117- if err := c.command(ctx, ports.PointerFocus{}); err != nil {117+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
118 return err118 return err
119 }119 }
120 }120 }
A internal/core/clientgesture.go +109 -0
@@ -0,0 +1,109 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Touchpad gestures the compositor does not use go to the client under the
11+// pointer (zwp_pointer_gestures_v1): swipes of any finger count but three
12+// and four, every pinch and every hold. Like scrolling, they follow the
13+// pointer focus. Three- and four-finger swipes stay with the compositor
14+// (gesture.go).
15+
16+// clientGesture is the gesture being forwarded to a client. Input streams
17+// one gesture at a time. A gesture that begins with no pointer focus, or
18+// whose focus moved, still takes its stream (on stays set) so the rest of
19+// it never reaches the compositor or another window.
20+type clientGesture struct {
21+ on bool
22+ kind ports.GestureKind
23+ // id is the window that got the begin; 0 once cancelled or when none had
24+ // the pointer.
25+ id WindowID
26+ // at is the device time of the latest event, for a cancellation.
27+ at time.Duration
28+}
29+
30+// forwardsSwipe reports whether the compositor leaves a swipe of that many
31+// fingers to the client.
32+func forwardsSwipe(fingers int) bool { return fingers != 3 && fingers != 4 }
33+
34+// gestureBegin starts forwarding a gesture to the window with the pointer
35+// focus. One still running (its end was lost) ends cancelled first.
36+func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers int, at time.Duration) error {
37+ if err := c.gestureAbort(ctx, at); err != nil {
38+ return err
39+ }
40+ c.gesture = clientGesture{on: true, kind: kind, at: at}
41+ if c.pointer == 0 || c.drag != nil {
42+ return nil
43+ }
44+ c.gesture.id = c.pointer
45+ return c.command(ctx, ports.GestureBeginTo{ID: c.pointer, Kind: kind, Fingers: fingers, Time: at})
46+}
47+
48+// gestureUpdate moves the forwarded gesture of that kind. A window that
49+// lost the pointer meanwhile gets its gesture cancelled.
50+func (c *Core) gestureUpdate(ctx context.Context, u ports.GestureUpdateTo) error {
51+ g := &c.gesture
52+ if !g.on || g.kind != u.Kind {
53+ return nil
54+ }
55+ g.at = u.Time
56+ if g.id == 0 {
57+ return nil
58+ }
59+ if g.id != c.pointer {
60+ return c.gestureCancel(ctx)
61+ }
62+ u.ID = g.id
63+ return c.command(ctx, u)
64+}
65+
66+// gestureEnd ends the forwarded gesture of that kind.
67+func (c *Core) gestureEnd(ctx context.Context, kind ports.GestureKind, cancelled bool, at time.Duration) error {
68+ g := c.gesture
69+ if !g.on || g.kind != kind {
70+ return nil
71+ }
72+ c.gesture = clientGesture{}
73+ if g.id == 0 {
74+ return nil
75+ }
76+ return c.command(ctx, ports.GestureEndTo{ID: g.id, Kind: kind, Cancelled: cancelled || g.id != c.pointer, Time: at})
77+}
78+
79+// gestureAbort ends whatever gesture is forwarded, cancelled.
80+func (c *Core) gestureAbort(ctx context.Context, at time.Duration) error {
81+ if !c.gesture.on {
82+ return nil
83+ }
84+ return c.gestureEnd(ctx, c.gesture.kind, true, at)
85+}
86+
87+// gestureCancel ends the gesture of the window that lost the pointer, as
88+// cancelled. The rest of the device's gesture is swallowed.
89+func (c *Core) gestureCancel(ctx context.Context) error {
90+ g := &c.gesture
91+ if !g.on || g.id == 0 {
92+ return nil
93+ }
94+ id := g.id
95+ g.id = 0
96+ return c.command(ctx, ports.GestureEndTo{ID: id, Kind: g.kind, Cancelled: true, Time: g.at})
97+}
98+
99+// pointerFocus sends a pointer focus change; the window leaving the
100+// pointer first gets its gesture cancelled, so its client sees the end
101+// before the leave.
102+func (c *Core) pointerFocus(ctx context.Context, f ports.PointerFocus) error {
103+ if c.gesture.id != 0 && c.gesture.id != f.ID {
104+ if err := c.gestureCancel(ctx); err != nil {
105+ return err
106+ }
107+ }
108+ return c.command(ctx, f)
109+}
@@ -0,0 +1,109 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Touchpad gestures the compositor does not use go to the client under the
11+// pointer (zwp_pointer_gestures_v1): swipes of any finger count but three
12+// and four, every pinch and every hold. Like scrolling, they follow the
13+// pointer focus. Three- and four-finger swipes stay with the compositor
14+// (gesture.go).
15+
16+// clientGesture is the gesture being forwarded to a client. Input streams
17+// one gesture at a time. A gesture that begins with no pointer focus, or
18+// whose focus moved, still takes its stream (on stays set) so the rest of
19+// it never reaches the compositor or another window.
20+type clientGesture struct {
21+ on bool
22+ kind ports.GestureKind
23+ // id is the window that got the begin; 0 once cancelled or when none had
24+ // the pointer.
25+ id WindowID
26+ // at is the device time of the latest event, for a cancellation.
27+ at time.Duration
28+}
29+
30+// forwardsSwipe reports whether the compositor leaves a swipe of that many
31+// fingers to the client.
32+func forwardsSwipe(fingers int) bool { return fingers != 3 && fingers != 4 }
33+
34+// gestureBegin starts forwarding a gesture to the window with the pointer
35+// focus. One still running (its end was lost) ends cancelled first.
36+func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers int, at time.Duration) error {
37+ if err := c.gestureAbort(ctx, at); err != nil {
38+ return err
39+ }
40+ c.gesture = clientGesture{on: true, kind: kind, at: at}
41+ if c.pointer == 0 || c.drag != nil {
42+ return nil
43+ }
44+ c.gesture.id = c.pointer
45+ return c.command(ctx, ports.GestureBeginTo{ID: c.pointer, Kind: kind, Fingers: fingers, Time: at})
46+}
47+
48+// gestureUpdate moves the forwarded gesture of that kind. A window that
49+// lost the pointer meanwhile gets its gesture cancelled.
50+func (c *Core) gestureUpdate(ctx context.Context, u ports.GestureUpdateTo) error {
51+ g := &c.gesture
52+ if !g.on || g.kind != u.Kind {
53+ return nil
54+ }
55+ g.at = u.Time
56+ if g.id == 0 {
57+ return nil
58+ }
59+ if g.id != c.pointer {
60+ return c.gestureCancel(ctx)
61+ }
62+ u.ID = g.id
63+ return c.command(ctx, u)
64+}
65+
66+// gestureEnd ends the forwarded gesture of that kind.
67+func (c *Core) gestureEnd(ctx context.Context, kind ports.GestureKind, cancelled bool, at time.Duration) error {
68+ g := c.gesture
69+ if !g.on || g.kind != kind {
70+ return nil
71+ }
72+ c.gesture = clientGesture{}
73+ if g.id == 0 {
74+ return nil
75+ }
76+ return c.command(ctx, ports.GestureEndTo{ID: g.id, Kind: kind, Cancelled: cancelled || g.id != c.pointer, Time: at})
77+}
78+
79+// gestureAbort ends whatever gesture is forwarded, cancelled.
80+func (c *Core) gestureAbort(ctx context.Context, at time.Duration) error {
81+ if !c.gesture.on {
82+ return nil
83+ }
84+ return c.gestureEnd(ctx, c.gesture.kind, true, at)
85+}
86+
87+// gestureCancel ends the gesture of the window that lost the pointer, as
88+// cancelled. The rest of the device's gesture is swallowed.
89+func (c *Core) gestureCancel(ctx context.Context) error {
90+ g := &c.gesture
91+ if !g.on || g.id == 0 {
92+ return nil
93+ }
94+ id := g.id
95+ g.id = 0
96+ return c.command(ctx, ports.GestureEndTo{ID: id, Kind: g.kind, Cancelled: true, Time: g.at})
97+}
98+
99+// pointerFocus sends a pointer focus change; the window leaving the
100+// pointer first gets its gesture cancelled, so its client sees the end
101+// before the leave.
102+func (c *Core) pointerFocus(ctx context.Context, f ports.PointerFocus) error {
103+ if c.gesture.id != 0 && c.gesture.id != f.ID {
104+ if err := c.gestureCancel(ctx); err != nil {
105+ return err
106+ }
107+ }
108+ return c.command(ctx, f)
109+}
A internal/core/clientgesture_test.go +283 -0
@@ -0,0 +1,283 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/config"
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+// gestureRig is a rig with window 1 mapped on the left output and the
16+// pointer on it. The right output is empty: the pointer there has no focus.
17+func gestureRig(t *testing.T) *multiRig {
18+ t.Helper()
19+ r := startMulti(t, nil, left, right)
20+ r.mapWindow(t, 1)
21+ r.input <- ports.PointerMotion{X: 50, Y: 50}
22+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
23+ t.Fatal(v)
24+ }
25+ if v := pointerCommand(t, r.commands); v != (ports.PointerMotionTo{ID: 1, X: 50, Y: 50}) {
26+ t.Fatal(v)
27+ }
28+ return r
29+}
30+
31+// isPointerCommand reports whether a command is one the gesture tests follow:
32+// the pointer's and the gestures'.
33+func isPointerCommand(cmd ports.ClientCommand) bool {
34+ switch cmd.(type) {
35+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
36+ return true
37+ }
38+ return false
39+}
40+
41+// pointerCommand receives the next pointer or gesture command, skipping
42+// the configures and focus changes between them.
43+func pointerCommand(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
44+ t.Helper()
45+ for {
46+ if v := command(t, ch); isPointerCommand(v) {
47+ return v
48+ }
49+ }
50+}
51+
52+// idle fails the test when core sent a command meanwhile. A marker input
53+// goes first, so the check waits for core to have handled what came before.
54+func (r *multiRig) idle(t *testing.T) {
55+ t.Helper()
56+ r.input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}
57+ if v := pointerCommand(t, r.commands); v != (ports.PointerAxisTo{ID: 1, Axis: ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}}) {
58+ t.Fatalf("unexpected command %#v", v)
59+ }
60+}
61+
62+func TestPinchGoesToPointerFocus(t *testing.T) {
63+ r := gestureRig(t)
64+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
65+ r.input <- ports.PinchUpdate{DX: 1, DY: -2, Scale: 1.25, Rotation: 5, Time: 2 * time.Second}
66+ r.input <- ports.PinchEnd{Time: 3 * time.Second}
67+ for _, want := range []ports.ClientCommand{
68+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
69+ ports.GestureUpdateTo{ID: 1, Kind: ports.GesturePinch, DX: 1, DY: -2, Scale: 1.25, Rotation: 5, Time: 2 * time.Second},
70+ ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Time: 3 * time.Second},
71+ } {
72+ if v := pointerCommand(t, r.commands); v != want {
73+ t.Fatalf("got %#v, want %#v", v, want)
74+ }
75+ }
76+}
77+
78+func TestHoldGoesToPointerFocus(t *testing.T) {
79+ r := gestureRig(t)
80+ r.input <- ports.HoldBegin{Fingers: 3, Time: time.Second}
81+ r.input <- ports.HoldEnd{Cancelled: true, Time: 2 * time.Second}
82+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: time.Second}) {
83+ t.Fatal(v)
84+ }
85+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GestureHold, Cancelled: true, Time: 2 * time.Second}) {
86+ t.Fatal(v)
87+ }
88+}
89+
90+// Swipes of other finger counts than three and four go to the client; the
91+// compositor keeps those.
92+func TestSwipeFingerCounts(t *testing.T) {
93+ for _, fingers := range []int{2, 5} {
94+ r := gestureRig(t)
95+ r.input <- ports.SwipeBegin{Fingers: fingers, Time: time.Second}
96+ r.input <- ports.SwipeUpdate{DX: 3, DY: 4, Time: 2 * time.Second}
97+ r.input <- ports.SwipeEnd{Time: 3 * time.Second}
98+ for _, want := range []ports.ClientCommand{
99+ ports.GestureBeginTo{ID: 1, Kind: ports.GestureSwipe, Fingers: fingers, Time: time.Second},
100+ ports.GestureUpdateTo{ID: 1, Kind: ports.GestureSwipe, DX: 3, DY: 4, Time: 2 * time.Second},
101+ ports.GestureEndTo{ID: 1, Kind: ports.GestureSwipe, Time: 3 * time.Second},
102+ } {
103+ if v := pointerCommand(t, r.commands); v != want {
104+ t.Fatalf("%d fingers: got %#v, want %#v", fingers, v, want)
105+ }
106+ }
107+ }
108+ for _, fingers := range []int{3, 4} {
109+ r := gestureRig(t)
110+ r.input <- ports.SwipeBegin{Fingers: fingers, Time: time.Second}
111+ r.input <- ports.SwipeUpdate{DX: 40, DY: 0, Time: 2 * time.Second}
112+ r.input <- ports.SwipeEnd{Time: 3 * time.Second}
113+ r.idle(t)
114+ }
115+}
116+
117+// A gesture that began with no window under the pointer reaches nobody,
118+// even when the pointer enters a window meanwhile.
119+func TestGestureWithoutPointerFocus(t *testing.T) {
120+ r := gestureRig(t)
121+ r.input <- ports.PointerMotion{X: 300, Y: 50}
122+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
123+ t.Fatal(v)
124+ }
125+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
126+ r.input <- ports.PointerMotion{X: 50, Y: 50}
127+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
128+ t.Fatal(v)
129+ }
130+ if v := pointerCommand(t, r.commands); v != (ports.PointerMotionTo{ID: 1, X: 50, Y: 50}) {
131+ t.Fatal(v)
132+ }
133+ r.input <- ports.PinchUpdate{Scale: 2, Time: 2 * time.Second}
134+ r.input <- ports.PinchEnd{Time: 3 * time.Second}
135+ r.idle(t)
136+}
137+
138+// The window losing the pointer focus gets its gesture cancelled before the
139+// leave, and nothing of the rest.
140+func TestGestureCancelledOnFocusChange(t *testing.T) {
141+ r := gestureRig(t)
142+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
143+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second}) {
144+ t.Fatal(v)
145+ }
146+ r.input <- ports.PinchUpdate{Scale: 1.1, Time: 2 * time.Second}
147+ if v := pointerCommand(t, r.commands); v != (ports.GestureUpdateTo{ID: 1, Kind: ports.GesturePinch, Scale: 1.1, Time: 2 * time.Second}) {
148+ t.Fatal(v)
149+ }
150+ r.input <- ports.PointerMotion{X: 300, Y: 50}
151+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second}) {
152+ t.Fatal(v)
153+ }
154+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
155+ t.Fatal(v)
156+ }
157+ r.input <- ports.PinchUpdate{Scale: 1.2, Time: 3 * time.Second}
158+ r.input <- ports.PinchEnd{Time: 4 * time.Second}
159+ r.input <- ports.PointerMotion{X: 50, Y: 50}
160+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
161+ t.Fatal(v)
162+ }
163+}
164+
165+// A window that goes away mid-gesture has its gesture cancelled with the
166+// pointer focus.
167+func TestGestureCancelledOnUnmap(t *testing.T) {
168+ r := gestureRig(t)
169+ r.input <- ports.HoldBegin{Fingers: 3, Time: time.Second}
170+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: time.Second}) {
171+ t.Fatal(v)
172+ }
173+ r.client <- ports.WindowUnmapped{ID: 1}
174+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GestureHold, Cancelled: true, Time: time.Second}) {
175+ t.Fatal(v)
176+ }
177+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
178+ t.Fatal(v)
179+ }
180+}
181+
182+// A gesture whose end was lost ends cancelled when the next one begins.
183+func TestGestureBeginEndsPrevious(t *testing.T) {
184+ r := gestureRig(t)
185+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
186+ r.input <- ports.HoldBegin{Fingers: 3, Time: 2 * time.Second}
187+ for _, want := range []ports.ClientCommand{
188+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
189+ ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second},
190+ ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: 2 * time.Second},
191+ } {
192+ if v := pointerCommand(t, r.commands); v != want {
193+ t.Fatalf("got %#v, want %#v", v, want)
194+ }
195+ }
196+}
197+
198+// A protected session forwards no gesture to a window; one running when
199+// the session locks ends with the pointer focus.
200+func TestGesturesRefusedWhileLocked(t *testing.T) {
201+ var epoch atomic.Value
202+ epoch.Store(ports.SecurityState{})
203+ gate := portsmocks.NewMockSessionSecurity(t)
204+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
205+ r := &multiRig{
206+ client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 32),
207+ output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
208+ commands: make(chan ports.ClientCommand, 256), scenes: make(chan []ports.Scene, 1),
209+ }
210+ cfg := config.Defaults()
211+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Constraints: make(chan ports.PointerConstraint, 1)}, core.Options{Security: gate})
212+ if err != nil {
213+ t.Fatal(err)
214+ }
215+ ctx, cancel := context.WithCancel(context.Background())
216+ done := make(chan error, 1)
217+ go func() { done <- c.Run(ctx) }()
218+ t.Cleanup(func() { cancel(); <-done })
219+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 300, Height: 200}}
220+ scene(t, r.scenes)
221+ r.client <- ports.WindowMapped{ID: 1}
222+ scene(t, r.scenes)
223+ unlocked := ports.SecurityState{}
224+ r.input <- ports.SecurityInput{State: unlocked, Event: ports.PointerMotion{X: 50, Y: 50}}
225+ r.input <- ports.SecurityInput{State: unlocked, Event: ports.PinchBegin{Fingers: 2, Time: time.Second}}
226+ for _, want := range []ports.ClientCommand{
227+ ports.PointerFocus{ID: 1, X: 50, Y: 50},
228+ ports.PointerMotionTo{ID: 1, X: 50, Y: 50},
229+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
230+ } {
231+ if v := unwrap(t, r.commands, unlocked); v != want {
232+ t.Fatalf("got %#v, want %#v", v, want)
233+ }
234+ }
235+
236+ // The lock clears the pointer focus, which cancels the gesture in wayland.
237+ locked := ports.SecurityState{Generation: 1, Protected: true}
238+ epoch.Store(locked)
239+ r.client <- ports.SessionLockChanged{State: locked, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 300, Height: 200}}}
240+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return s.Security == locked && len(s.Windows) == 1 })
241+ r.input <- ports.SecurityInput{State: locked, Event: ports.PointerMotion{X: 50, Y: 50}}
242+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchBegin{Fingers: 2, Time: 2 * time.Second}}
243+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchUpdate{Scale: 2, Time: 3 * time.Second}}
244+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchEnd{Time: 4 * time.Second}}
245+ r.input <- ports.SecurityInput{State: locked, Event: ports.HoldBegin{Fingers: 3, Time: 5 * time.Second}}
246+ r.input <- ports.SecurityInput{State: locked, Event: ports.HoldEnd{Time: 6 * time.Second}}
247+ r.input <- ports.SecurityInput{State: locked, Event: ports.SwipeBegin{Fingers: 2, Time: 7 * time.Second}}
248+ r.input <- ports.SecurityInput{State: locked, Event: ports.SwipeEnd{Time: 8 * time.Second}}
249+ r.input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "marker", Keycode: 30, Pressed: true}}
250+ for {
251+ wrapped, ok := receive(t, r.commands).(ports.SecurityCommand)
252+ if !ok {
253+ t.Fatal("unwrapped command")
254+ }
255+ switch v := wrapped.Command.(type) {
256+ case ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
257+ if wrapped.State == locked {
258+ t.Fatalf("gesture forwarded while locked: %#v", v)
259+ }
260+ case ports.ForwardKey:
261+ if wrapped.State == locked {
262+ return
263+ }
264+ }
265+ }
266+}
267+
268+// unwrap returns the next pointer command of an envelope of the given epoch.
269+func unwrap(t *testing.T, ch <-chan ports.ClientCommand, state ports.SecurityState) ports.ClientCommand {
270+ t.Helper()
271+ for {
272+ wrapped, ok := receive(t, ch).(ports.SecurityCommand)
273+ if !ok {
274+ t.Fatal("unwrapped command")
275+ }
276+ if wrapped.State != state {
277+ t.Fatalf("epoch %+v, want %+v", wrapped.State, state)
278+ }
279+ if isPointerCommand(wrapped.Command) {
280+ return wrapped.Command
281+ }
282+ }
283+}
@@ -0,0 +1,283 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/config"
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+// gestureRig is a rig with window 1 mapped on the left output and the
16+// pointer on it. The right output is empty: the pointer there has no focus.
17+func gestureRig(t *testing.T) *multiRig {
18+ t.Helper()
19+ r := startMulti(t, nil, left, right)
20+ r.mapWindow(t, 1)
21+ r.input <- ports.PointerMotion{X: 50, Y: 50}
22+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
23+ t.Fatal(v)
24+ }
25+ if v := pointerCommand(t, r.commands); v != (ports.PointerMotionTo{ID: 1, X: 50, Y: 50}) {
26+ t.Fatal(v)
27+ }
28+ return r
29+}
30+
31+// isPointerCommand reports whether a command is one the gesture tests follow:
32+// the pointer's and the gestures'.
33+func isPointerCommand(cmd ports.ClientCommand) bool {
34+ switch cmd.(type) {
35+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
36+ return true
37+ }
38+ return false
39+}
40+
41+// pointerCommand receives the next pointer or gesture command, skipping
42+// the configures and focus changes between them.
43+func pointerCommand(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
44+ t.Helper()
45+ for {
46+ if v := command(t, ch); isPointerCommand(v) {
47+ return v
48+ }
49+ }
50+}
51+
52+// idle fails the test when core sent a command meanwhile. A marker input
53+// goes first, so the check waits for core to have handled what came before.
54+func (r *multiRig) idle(t *testing.T) {
55+ t.Helper()
56+ r.input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}
57+ if v := pointerCommand(t, r.commands); v != (ports.PointerAxisTo{ID: 1, Axis: ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}}) {
58+ t.Fatalf("unexpected command %#v", v)
59+ }
60+}
61+
62+func TestPinchGoesToPointerFocus(t *testing.T) {
63+ r := gestureRig(t)
64+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
65+ r.input <- ports.PinchUpdate{DX: 1, DY: -2, Scale: 1.25, Rotation: 5, Time: 2 * time.Second}
66+ r.input <- ports.PinchEnd{Time: 3 * time.Second}
67+ for _, want := range []ports.ClientCommand{
68+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
69+ ports.GestureUpdateTo{ID: 1, Kind: ports.GesturePinch, DX: 1, DY: -2, Scale: 1.25, Rotation: 5, Time: 2 * time.Second},
70+ ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Time: 3 * time.Second},
71+ } {
72+ if v := pointerCommand(t, r.commands); v != want {
73+ t.Fatalf("got %#v, want %#v", v, want)
74+ }
75+ }
76+}
77+
78+func TestHoldGoesToPointerFocus(t *testing.T) {
79+ r := gestureRig(t)
80+ r.input <- ports.HoldBegin{Fingers: 3, Time: time.Second}
81+ r.input <- ports.HoldEnd{Cancelled: true, Time: 2 * time.Second}
82+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: time.Second}) {
83+ t.Fatal(v)
84+ }
85+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GestureHold, Cancelled: true, Time: 2 * time.Second}) {
86+ t.Fatal(v)
87+ }
88+}
89+
90+// Swipes of other finger counts than three and four go to the client; the
91+// compositor keeps those.
92+func TestSwipeFingerCounts(t *testing.T) {
93+ for _, fingers := range []int{2, 5} {
94+ r := gestureRig(t)
95+ r.input <- ports.SwipeBegin{Fingers: fingers, Time: time.Second}
96+ r.input <- ports.SwipeUpdate{DX: 3, DY: 4, Time: 2 * time.Second}
97+ r.input <- ports.SwipeEnd{Time: 3 * time.Second}
98+ for _, want := range []ports.ClientCommand{
99+ ports.GestureBeginTo{ID: 1, Kind: ports.GestureSwipe, Fingers: fingers, Time: time.Second},
100+ ports.GestureUpdateTo{ID: 1, Kind: ports.GestureSwipe, DX: 3, DY: 4, Time: 2 * time.Second},
101+ ports.GestureEndTo{ID: 1, Kind: ports.GestureSwipe, Time: 3 * time.Second},
102+ } {
103+ if v := pointerCommand(t, r.commands); v != want {
104+ t.Fatalf("%d fingers: got %#v, want %#v", fingers, v, want)
105+ }
106+ }
107+ }
108+ for _, fingers := range []int{3, 4} {
109+ r := gestureRig(t)
110+ r.input <- ports.SwipeBegin{Fingers: fingers, Time: time.Second}
111+ r.input <- ports.SwipeUpdate{DX: 40, DY: 0, Time: 2 * time.Second}
112+ r.input <- ports.SwipeEnd{Time: 3 * time.Second}
113+ r.idle(t)
114+ }
115+}
116+
117+// A gesture that began with no window under the pointer reaches nobody,
118+// even when the pointer enters a window meanwhile.
119+func TestGestureWithoutPointerFocus(t *testing.T) {
120+ r := gestureRig(t)
121+ r.input <- ports.PointerMotion{X: 300, Y: 50}
122+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
123+ t.Fatal(v)
124+ }
125+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
126+ r.input <- ports.PointerMotion{X: 50, Y: 50}
127+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
128+ t.Fatal(v)
129+ }
130+ if v := pointerCommand(t, r.commands); v != (ports.PointerMotionTo{ID: 1, X: 50, Y: 50}) {
131+ t.Fatal(v)
132+ }
133+ r.input <- ports.PinchUpdate{Scale: 2, Time: 2 * time.Second}
134+ r.input <- ports.PinchEnd{Time: 3 * time.Second}
135+ r.idle(t)
136+}
137+
138+// The window losing the pointer focus gets its gesture cancelled before the
139+// leave, and nothing of the rest.
140+func TestGestureCancelledOnFocusChange(t *testing.T) {
141+ r := gestureRig(t)
142+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
143+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second}) {
144+ t.Fatal(v)
145+ }
146+ r.input <- ports.PinchUpdate{Scale: 1.1, Time: 2 * time.Second}
147+ if v := pointerCommand(t, r.commands); v != (ports.GestureUpdateTo{ID: 1, Kind: ports.GesturePinch, Scale: 1.1, Time: 2 * time.Second}) {
148+ t.Fatal(v)
149+ }
150+ r.input <- ports.PointerMotion{X: 300, Y: 50}
151+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second}) {
152+ t.Fatal(v)
153+ }
154+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
155+ t.Fatal(v)
156+ }
157+ r.input <- ports.PinchUpdate{Scale: 1.2, Time: 3 * time.Second}
158+ r.input <- ports.PinchEnd{Time: 4 * time.Second}
159+ r.input <- ports.PointerMotion{X: 50, Y: 50}
160+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{ID: 1, X: 50, Y: 50}) {
161+ t.Fatal(v)
162+ }
163+}
164+
165+// A window that goes away mid-gesture has its gesture cancelled with the
166+// pointer focus.
167+func TestGestureCancelledOnUnmap(t *testing.T) {
168+ r := gestureRig(t)
169+ r.input <- ports.HoldBegin{Fingers: 3, Time: time.Second}
170+ if v := pointerCommand(t, r.commands); v != (ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: time.Second}) {
171+ t.Fatal(v)
172+ }
173+ r.client <- ports.WindowUnmapped{ID: 1}
174+ if v := pointerCommand(t, r.commands); v != (ports.GestureEndTo{ID: 1, Kind: ports.GestureHold, Cancelled: true, Time: time.Second}) {
175+ t.Fatal(v)
176+ }
177+ if v := pointerCommand(t, r.commands); v != (ports.PointerFocus{}) {
178+ t.Fatal(v)
179+ }
180+}
181+
182+// A gesture whose end was lost ends cancelled when the next one begins.
183+func TestGestureBeginEndsPrevious(t *testing.T) {
184+ r := gestureRig(t)
185+ r.input <- ports.PinchBegin{Fingers: 2, Time: time.Second}
186+ r.input <- ports.HoldBegin{Fingers: 3, Time: 2 * time.Second}
187+ for _, want := range []ports.ClientCommand{
188+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
189+ ports.GestureEndTo{ID: 1, Kind: ports.GesturePinch, Cancelled: true, Time: 2 * time.Second},
190+ ports.GestureBeginTo{ID: 1, Kind: ports.GestureHold, Fingers: 3, Time: 2 * time.Second},
191+ } {
192+ if v := pointerCommand(t, r.commands); v != want {
193+ t.Fatalf("got %#v, want %#v", v, want)
194+ }
195+ }
196+}
197+
198+// A protected session forwards no gesture to a window; one running when
199+// the session locks ends with the pointer focus.
200+func TestGesturesRefusedWhileLocked(t *testing.T) {
201+ var epoch atomic.Value
202+ epoch.Store(ports.SecurityState{})
203+ gate := portsmocks.NewMockSessionSecurity(t)
204+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
205+ r := &multiRig{
206+ client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 32),
207+ output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
208+ commands: make(chan ports.ClientCommand, 256), scenes: make(chan []ports.Scene, 1),
209+ }
210+ cfg := config.Defaults()
211+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Constraints: make(chan ports.PointerConstraint, 1)}, core.Options{Security: gate})
212+ if err != nil {
213+ t.Fatal(err)
214+ }
215+ ctx, cancel := context.WithCancel(context.Background())
216+ done := make(chan error, 1)
217+ go func() { done <- c.Run(ctx) }()
218+ t.Cleanup(func() { cancel(); <-done })
219+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 300, Height: 200}}
220+ scene(t, r.scenes)
221+ r.client <- ports.WindowMapped{ID: 1}
222+ scene(t, r.scenes)
223+ unlocked := ports.SecurityState{}
224+ r.input <- ports.SecurityInput{State: unlocked, Event: ports.PointerMotion{X: 50, Y: 50}}
225+ r.input <- ports.SecurityInput{State: unlocked, Event: ports.PinchBegin{Fingers: 2, Time: time.Second}}
226+ for _, want := range []ports.ClientCommand{
227+ ports.PointerFocus{ID: 1, X: 50, Y: 50},
228+ ports.PointerMotionTo{ID: 1, X: 50, Y: 50},
229+ ports.GestureBeginTo{ID: 1, Kind: ports.GesturePinch, Fingers: 2, Time: time.Second},
230+ } {
231+ if v := unwrap(t, r.commands, unlocked); v != want {
232+ t.Fatalf("got %#v, want %#v", v, want)
233+ }
234+ }
235+
236+ // The lock clears the pointer focus, which cancels the gesture in wayland.
237+ locked := ports.SecurityState{Generation: 1, Protected: true}
238+ epoch.Store(locked)
239+ r.client <- ports.SessionLockChanged{State: locked, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 300, Height: 200}}}
240+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return s.Security == locked && len(s.Windows) == 1 })
241+ r.input <- ports.SecurityInput{State: locked, Event: ports.PointerMotion{X: 50, Y: 50}}
242+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchBegin{Fingers: 2, Time: 2 * time.Second}}
243+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchUpdate{Scale: 2, Time: 3 * time.Second}}
244+ r.input <- ports.SecurityInput{State: locked, Event: ports.PinchEnd{Time: 4 * time.Second}}
245+ r.input <- ports.SecurityInput{State: locked, Event: ports.HoldBegin{Fingers: 3, Time: 5 * time.Second}}
246+ r.input <- ports.SecurityInput{State: locked, Event: ports.HoldEnd{Time: 6 * time.Second}}
247+ r.input <- ports.SecurityInput{State: locked, Event: ports.SwipeBegin{Fingers: 2, Time: 7 * time.Second}}
248+ r.input <- ports.SecurityInput{State: locked, Event: ports.SwipeEnd{Time: 8 * time.Second}}
249+ r.input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "marker", Keycode: 30, Pressed: true}}
250+ for {
251+ wrapped, ok := receive(t, r.commands).(ports.SecurityCommand)
252+ if !ok {
253+ t.Fatal("unwrapped command")
254+ }
255+ switch v := wrapped.Command.(type) {
256+ case ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
257+ if wrapped.State == locked {
258+ t.Fatalf("gesture forwarded while locked: %#v", v)
259+ }
260+ case ports.ForwardKey:
261+ if wrapped.State == locked {
262+ return
263+ }
264+ }
265+ }
266+}
267+
268+// unwrap returns the next pointer command of an envelope of the given epoch.
269+func unwrap(t *testing.T, ch <-chan ports.ClientCommand, state ports.SecurityState) ports.ClientCommand {
270+ t.Helper()
271+ for {
272+ wrapped, ok := receive(t, ch).(ports.SecurityCommand)
273+ if !ok {
274+ t.Fatal("unwrapped command")
275+ }
276+ if wrapped.State != state {
277+ t.Fatalf("epoch %+v, want %+v", wrapped.State, state)
278+ }
279+ if isPointerCommand(wrapped.Command) {
280+ return wrapped.Command
281+ }
282+ }
283+}
M internal/core/core.go +6 -3
@@ -133,7 +133,10 @@ type Core struct {
133133 // flip came in time to move a running slide; nil when not armed. It is
134134 // the channel of frameTimer, made once and reset per frame: an
135135 // animation frame allocates no timer.
136- swipe *swipeGesture
136+ swipe *swipeGesture
137+ // gesture is the touchpad gesture forwarded to a client
138+ // (clientgesture.go).
139+ gesture clientGesture
137140 frameC <-chan time.Time
138141 frameTimer ports.Timer
139142 // shots is the snapshot of what the screens show before an action
@@ -609,7 +612,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
609612 if c.pointer != 0 && !c.visible(c.pointer) {
610613 c.pointer = 0
611614 if c.grab == 0 {
612- if err := c.command(ctx, ports.PointerFocus{}); err != nil {
615+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
613616 return err
614617 }
615618 }
@@ -691,7 +694,7 @@ func (c *Core) rehit(ctx context.Context) error {
691694 id, x, y := c.hit(c.cursorX, c.cursorY)
692695 if id != c.pointer {
693696 c.pointer, c.pointerAt = id, [2]float64{x, y}
694- return c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y})
697+ return c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y})
695698 }
696699 if id == 0 || c.pointerAt == [2]float64{x, y} {
697700 return nil
@@ -133,7 +133,10 @@ type Core struct {
133 // flip came in time to move a running slide; nil when not armed. It is133 // flip came in time to move a running slide; nil when not armed. It is
134 // the channel of frameTimer, made once and reset per frame: an134 // the channel of frameTimer, made once and reset per frame: an
135 // animation frame allocates no timer.135 // animation frame allocates no timer.
136- swipe *swipeGesture136+ swipe *swipeGesture
137+ // gesture is the touchpad gesture forwarded to a client
138+ // (clientgesture.go).
139+ gesture clientGesture
137 frameC <-chan time.Time140 frameC <-chan time.Time
138 frameTimer ports.Timer141 frameTimer ports.Timer
139 // shots is the snapshot of what the screens show before an action142 // shots is the snapshot of what the screens show before an action
@@ -609,7 +612,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
609 if c.pointer != 0 && !c.visible(c.pointer) {612 if c.pointer != 0 && !c.visible(c.pointer) {
610 c.pointer = 0613 c.pointer = 0
611 if c.grab == 0 {614 if c.grab == 0 {
612- if err := c.command(ctx, ports.PointerFocus{}); err != nil {615+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
613 return err616 return err
614 }617 }
615 }618 }
@@ -691,7 +694,7 @@ func (c *Core) rehit(ctx context.Context) error {
691 id, x, y := c.hit(c.cursorX, c.cursorY)694 id, x, y := c.hit(c.cursorX, c.cursorY)
692 if id != c.pointer {695 if id != c.pointer {
693 c.pointer, c.pointerAt = id, [2]float64{x, y}696 c.pointer, c.pointerAt = id, [2]float64{x, y}
694- return c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y})697+ return c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y})
695 }698 }
696 if id == 0 || c.pointerAt == [2]float64{x, y} {699 if id == 0 || c.pointerAt == [2]float64{x, y} {
697 return nil700 return nil
M internal/core/drag.go +1 -1
@@ -165,7 +165,7 @@ func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize
165165 c.grab = 0
166166 if c.pointer != 0 {
167167 c.pointer = 0
168- if err := c.command(ctx, ports.PointerFocus{}); err != nil {
168+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
169169 return err
170170 }
171171 }
@@ -165,7 +165,7 @@ func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize
165 c.grab = 0165 c.grab = 0
166 if c.pointer != 0 {166 if c.pointer != 0 {
167 c.pointer = 0167 c.pointer = 0
168- if err := c.command(ctx, ports.PointerFocus{}); err != nil {168+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
169 return err169 return err
170 }170 }
171 }171 }
M internal/core/input.go +23 -1
@@ -26,18 +26,40 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
2626 case ports.PointerAxis:
2727 return c.pointerAxis(ctx, v)
2828 case ports.SwipeBegin:
29+ if forwardsSwipe(v.Fingers) {
30+ return c.gestureBegin(ctx, ports.GestureSwipe, v.Fingers, v.Time)
31+ }
32+ if err := c.gestureAbort(ctx, v.Time); err != nil {
33+ return err
34+ }
2935 if c.swipeBegin(v) {
3036 return c.slid(ctx, false)
3137 }
3238 return nil
3339 case ports.SwipeUpdate:
40+ if c.gesture.on {
41+ return c.gestureUpdate(ctx, ports.GestureUpdateTo{Kind: ports.GestureSwipe, DX: v.DX, DY: v.DY, Time: v.Time})
42+ }
3443 before := c.cur().mon.Current()
3544 if c.swipeUpdate(v) {
3645 return c.slid(ctx, c.cur().mon.Current() != before)
3746 }
3847 return nil
3948 case ports.SwipeEnd:
49+ if c.gesture.on && c.gesture.kind == ports.GestureSwipe {
50+ return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)
51+ }
4052 return c.slid(ctx, c.swipeEnd(v))
53+ case ports.PinchBegin:
54+ return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)
55+ case ports.PinchUpdate:
56+ return c.gestureUpdate(ctx, ports.GestureUpdateTo{Kind: ports.GesturePinch, DX: v.DX, DY: v.DY, Scale: v.Scale, Rotation: v.Rotation, Time: v.Time})
57+ case ports.PinchEnd:
58+ return c.gestureEnd(ctx, ports.GesturePinch, v.Cancelled, v.Time)
59+ case ports.HoldBegin:
60+ return c.gestureBegin(ctx, ports.GestureHold, v.Fingers, v.Time)
61+ case ports.HoldEnd:
62+ return c.gestureEnd(ctx, ports.GestureHold, v.Cancelled, v.Time)
4163 case ports.KeyEvent:
4264 return c.keyEvent(ctx, v)
4365 }
@@ -73,7 +95,7 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
7395 // Layout changes are intentionally re-hit-tested only on motion.
7496 if id != c.pointer {
7597 c.pointer = id
76- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
98+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
7799 return err
78100 }
79101 }
@@ -26,18 +26,40 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
26 case ports.PointerAxis:26 case ports.PointerAxis:
27 return c.pointerAxis(ctx, v)27 return c.pointerAxis(ctx, v)
28 case ports.SwipeBegin:28 case ports.SwipeBegin:
29+ if forwardsSwipe(v.Fingers) {
30+ return c.gestureBegin(ctx, ports.GestureSwipe, v.Fingers, v.Time)
31+ }
32+ if err := c.gestureAbort(ctx, v.Time); err != nil {
33+ return err
34+ }
29 if c.swipeBegin(v) {35 if c.swipeBegin(v) {
30 return c.slid(ctx, false)36 return c.slid(ctx, false)
31 }37 }
32 return nil38 return nil
33 case ports.SwipeUpdate:39 case ports.SwipeUpdate:
40+ if c.gesture.on {
41+ return c.gestureUpdate(ctx, ports.GestureUpdateTo{Kind: ports.GestureSwipe, DX: v.DX, DY: v.DY, Time: v.Time})
42+ }
34 before := c.cur().mon.Current()43 before := c.cur().mon.Current()
35 if c.swipeUpdate(v) {44 if c.swipeUpdate(v) {
36 return c.slid(ctx, c.cur().mon.Current() != before)45 return c.slid(ctx, c.cur().mon.Current() != before)
37 }46 }
38 return nil47 return nil
39 case ports.SwipeEnd:48 case ports.SwipeEnd:
49+ if c.gesture.on && c.gesture.kind == ports.GestureSwipe {
50+ return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)
51+ }
40 return c.slid(ctx, c.swipeEnd(v))52 return c.slid(ctx, c.swipeEnd(v))
53+ case ports.PinchBegin:
54+ return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)
55+ case ports.PinchUpdate:
56+ return c.gestureUpdate(ctx, ports.GestureUpdateTo{Kind: ports.GesturePinch, DX: v.DX, DY: v.DY, Scale: v.Scale, Rotation: v.Rotation, Time: v.Time})
57+ case ports.PinchEnd:
58+ return c.gestureEnd(ctx, ports.GesturePinch, v.Cancelled, v.Time)
59+ case ports.HoldBegin:
60+ return c.gestureBegin(ctx, ports.GestureHold, v.Fingers, v.Time)
61+ case ports.HoldEnd:
62+ return c.gestureEnd(ctx, ports.GestureHold, v.Cancelled, v.Time)
41 case ports.KeyEvent:63 case ports.KeyEvent:
42 return c.keyEvent(ctx, v)64 return c.keyEvent(ctx, v)
43 }65 }
@@ -73,7 +95,7 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
73 // Layout changes are intentionally re-hit-tested only on motion.95 // Layout changes are intentionally re-hit-tested only on motion.
74 if id != c.pointer {96 if id != c.pointer {
75 c.pointer = id97 c.pointer = id
76- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {98+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
77 return err99 return err
78 }100 }
79 }101 }
M internal/core/security.go +6 -3
@@ -41,6 +41,9 @@ func (c *Core) syncSecurity() bool {
4141 }
4242 c.dropSwipe()
4343 c.swipe = nil
44+ // The gesture ends with the pointer focus: wayland cancels it when the
45+ // protected epoch clears that focus.
46+ c.gesture = clientGesture{}
4447 c.stopAnimations()
4548 c.stopPulse()
4649 if state.Protected {
@@ -64,7 +67,7 @@ func (c *Core) securityCheckpoint(ctx context.Context) error {
6467 return nil
6568 }
6669 // Clear display focus even if the new epoch has no mapped lock surfaces.
67- if err := c.command(ctx, ports.PointerFocus{}); err != nil {
70+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
6871 return err
6972 }
7073 if err := c.command(ctx, ports.FocusWindow{}); err != nil {
@@ -204,7 +207,7 @@ func (c *Core) publishProtected(ctx context.Context) error {
204207 if !c.validLockID(c.pointer) {
205208 c.pointer = 0
206209 }
207- if err := c.command(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil {
210+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil {
208211 return err
209212 }
210213 latest(c.ch.Scenes, scenes)
@@ -259,7 +262,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
259262 id, x, y := c.lockHit(c.cursorX, c.cursorY)
260263 if id != c.pointer {
261264 c.pointer = id
262- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
265+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
263266 return err
264267 }
265268 }
@@ -41,6 +41,9 @@ func (c *Core) syncSecurity() bool {
41 }41 }
42 c.dropSwipe()42 c.dropSwipe()
43 c.swipe = nil43 c.swipe = nil
44+ // The gesture ends with the pointer focus: wayland cancels it when the
45+ // protected epoch clears that focus.
46+ c.gesture = clientGesture{}
44 c.stopAnimations()47 c.stopAnimations()
45 c.stopPulse()48 c.stopPulse()
46 if state.Protected {49 if state.Protected {
@@ -64,7 +67,7 @@ func (c *Core) securityCheckpoint(ctx context.Context) error {
64 return nil67 return nil
65 }68 }
66 // Clear display focus even if the new epoch has no mapped lock surfaces.69 // Clear display focus even if the new epoch has no mapped lock surfaces.
67- if err := c.command(ctx, ports.PointerFocus{}); err != nil {70+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
68 return err71 return err
69 }72 }
70 if err := c.command(ctx, ports.FocusWindow{}); err != nil {73 if err := c.command(ctx, ports.FocusWindow{}); err != nil {
@@ -204,7 +207,7 @@ func (c *Core) publishProtected(ctx context.Context) error {
204 if !c.validLockID(c.pointer) {207 if !c.validLockID(c.pointer) {
205 c.pointer = 0208 c.pointer = 0
206 }209 }
207- if err := c.command(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil {210+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil {
208 return err211 return err
209 }212 }
210 latest(c.ch.Scenes, scenes)213 latest(c.ch.Scenes, scenes)
@@ -259,7 +262,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
259 id, x, y := c.lockHit(c.cursorX, c.cursorY)262 id, x, y := c.lockHit(c.cursorX, c.cursorY)
260 if id != c.pointer {263 if id != c.pointer {
261 c.pointer = id264 c.pointer = id
262- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {265+ if err := c.pointerFocus(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
263 return err266 return err
264 }267 }
265 }268 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close