detached 4634bd8

feat(core): route data drags to the surface under the pointer

commit
4634bd818e1fd84a0f36bf5fb0c0c4d60f594130
author
brice · · 2026-10-17 14:17 UTC
parent
c2603da
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +692 -4
M internal/core/client.go +21 -0
@@ -84,6 +84,24 @@ func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish,
8484 if c.clientDrag(ctx, v) != nil {
8585 return false, true
8686 }
87+ case ports.DataDragStarted:
88+ if c.startDataDrag(ctx, v) != nil {
89+ return false, true
90+ }
91+ return false, false
92+ case ports.DataDragIcon:
93+ if !c.setDataDragIcon(v) {
94+ return false, false
95+ }
96+ case ports.DataDragEnded:
97+ if c.dataDrag == nil {
98+ return false, false
99+ }
100+ c.abortDataDrag()
101+ if c.finishDataDrag(ctx) != nil {
102+ return false, true
103+ }
104+ return false, false
87105 case ports.WindowFullscreenRequest:
88106 if c.fullscreenRequest(ctx, v) != nil {
89107 return false, true
@@ -114,6 +132,9 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
114132 if c.drag != nil && c.drag.id == v.ID {
115133 c.abortDrag()
116134 }
135+ if err := c.dataDragGone(ctx, v.ID); err != nil {
136+ return err
137+ }
117138 c.unmapWindow(v)
118139 c.releaseSlots()
119140 if c.pointer == v.ID {
@@ -84,6 +84,24 @@ func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish,
84 if c.clientDrag(ctx, v) != nil {84 if c.clientDrag(ctx, v) != nil {
85 return false, true85 return false, true
86 }86 }
87+ case ports.DataDragStarted:
88+ if c.startDataDrag(ctx, v) != nil {
89+ return false, true
90+ }
91+ return false, false
92+ case ports.DataDragIcon:
93+ if !c.setDataDragIcon(v) {
94+ return false, false
95+ }
96+ case ports.DataDragEnded:
97+ if c.dataDrag == nil {
98+ return false, false
99+ }
100+ c.abortDataDrag()
101+ if c.finishDataDrag(ctx) != nil {
102+ return false, true
103+ }
104+ return false, false
87 case ports.WindowFullscreenRequest:105 case ports.WindowFullscreenRequest:
88 if c.fullscreenRequest(ctx, v) != nil {106 if c.fullscreenRequest(ctx, v) != nil {
89 return false, true107 return false, true
@@ -114,6 +132,9 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
114 if c.drag != nil && c.drag.id == v.ID {132 if c.drag != nil && c.drag.id == v.ID {
115 c.abortDrag()133 c.abortDrag()
116 }134 }
135+ if err := c.dataDragGone(ctx, v.ID); err != nil {
136+ return err
137+ }
117 c.unmapWindow(v)138 c.unmapWindow(v)
118 c.releaseSlots()139 c.releaseSlots()
119 if c.pointer == v.ID {140 if c.pointer == v.ID {
M internal/core/clientgesture.go +1 -1
@@ -38,7 +38,7 @@ func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers
3838 return err
3939 }
4040 c.gesture = clientGesture{on: true, kind: kind, at: at}
41- if c.pointer == 0 || c.drag != nil {
41+ if c.pointer == 0 || c.drag != nil || c.dataDrag != nil {
4242 return nil
4343 }
4444 c.gesture.id = c.pointer
@@ -38,7 +38,7 @@ func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers
38 return err38 return err
39 }39 }
40 c.gesture = clientGesture{on: true, kind: kind, at: at}40 c.gesture = clientGesture{on: true, kind: kind, at: at}
41- if c.pointer == 0 || c.drag != nil {41+ if c.pointer == 0 || c.drag != nil || c.dataDrag != nil {
42 return nil42 return nil
43 }43 }
44 c.gesture.id = c.pointer44 c.gesture.id = c.pointer
M internal/core/core.go +4 -2
@@ -101,7 +101,9 @@ type Core struct {
101101 // buttons whose release no client must see. mods are the modifiers
102102 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
103103 // press a client saw, the one xdg_toplevel.move's serial names.
104- drag *dragState
104+ drag *dragState
105+ // dataDrag is the client data drag in progress (datadrag.go);
106+ dataDrag *dataDragState
105107 swallow map[uint32]bool
106108 lastButton uint32
107109 mods ports.Mods
@@ -760,7 +762,7 @@ func (c *Core) slidFrame(ctx context.Context, shown bool, only *screen) error {
760762 // rehit points the pointer at what now lies under a still cursor, as
761763 // windows slide under it. A held button keeps its grab.
762764 func (c *Core) rehit(ctx context.Context) error {
763- if c.grab != 0 || c.drag != nil {
765+ if c.grab != 0 || c.drag != nil || c.dataDrag != nil {
764766 return nil
765767 }
766768 id, x, y := c.hit(c.cursorX, c.cursorY)
@@ -101,7 +101,9 @@ type Core struct {
101 // buttons whose release no client must see. mods are the modifiers101 // buttons whose release no client must see. mods are the modifiers
102 // held, cmdMod the one keyboard.cmd names. lastButton is the latest102 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
103 // press a client saw, the one xdg_toplevel.move's serial names.103 // press a client saw, the one xdg_toplevel.move's serial names.
104- drag *dragState104+ drag *dragState
105+ // dataDrag is the client data drag in progress (datadrag.go);
106+ dataDrag *dataDragState
105 swallow map[uint32]bool107 swallow map[uint32]bool
106 lastButton uint32108 lastButton uint32
107 mods ports.Mods109 mods ports.Mods
@@ -760,7 +762,7 @@ func (c *Core) slidFrame(ctx context.Context, shown bool, only *screen) error {
760 // rehit points the pointer at what now lies under a still cursor, as762 // rehit points the pointer at what now lies under a still cursor, as
761 // windows slide under it. A held button keeps its grab.763 // windows slide under it. A held button keeps its grab.
762 func (c *Core) rehit(ctx context.Context) error {764 func (c *Core) rehit(ctx context.Context) error {
763- if c.grab != 0 || c.drag != nil {765+ if c.grab != 0 || c.drag != nil || c.dataDrag != nil {
764 return nil766 return nil
765 }767 }
766 id, x, y := c.hit(c.cursorX, c.cursorY)768 id, x, y := c.hit(c.cursorX, c.cursorY)
A internal/core/datadrag.go +181 -0
@@ -0,0 +1,181 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Data drag: a client drag and drop (wl_data_device.start_drag). Wayland
11+// validated the request against the client's press and tells core; core
12+// owns the pointer for the length of the drag, as for a window drag
13+// (drag.go):
14+//
15+// - the origin client gets the pointer leave and no pointer event after;
16+// nobody else gets one: the surface under the pointer is the drag
17+// target, told to wayland as DataDragFocus and DataDragMotion;
18+// - releasing the drag button drops (DataDragDrop), Escape cancels
19+// (DataDragCancel); neither reaches a client;
20+// - scrolling and touchpad gestures go nowhere; the keyboard keeps its
21+// focus, clients read the modifiers that choose the action from it;
22+// - the drag icon, a client surface wayland measures (DataDragIcon), is
23+// drawn at the cursor over everything.
24+//
25+// Layout changes under a still cursor, like an unmapped target, are
26+// resolved at the next motion.
27+
28+// dataDragState is the data drag in progress.
29+type dataDragState struct {
30+ // button is the pointer button that holds the drag.
31+ button uint32
32+ // target is the surface the drag was last routed to (0: none).
33+ target WindowID
34+ icon dataDragIcon
35+}
36+
37+// dataDragIcon is the icon surface: its top left corner relative to the
38+// cursor and its size, logical px. Hidden while W or H is 0.
39+type dataDragIcon struct {
40+ id WindowID
41+ x, y, w, h int
42+}
43+
44+// startDataDrag answers the start_drag wayland validated: it takes the
45+// pointer if a client holds a button, else cancels the drag. A drag
46+// during a window drag or a locked session is refused.
47+func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {
48+ button := c.lastButton
49+ if c.dataDrag != nil || c.drag != nil || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
50+ return c.command(ctx, ports.DataDragCancel{})
51+ }
52+ c.dataDrag = &dataDragState{button: button, icon: dataDragIcon{id: v.Icon}}
53+ // Other held buttons release without a client to tell.
54+ for b := range c.buttons {
55+ if b != button {
56+ c.swallow[b] = true
57+ }
58+ }
59+ c.grab = 0
60+ // The origin gets its pointer leave, as the drag takes the pointer.
61+ if c.pointer != 0 {
62+ c.pointer = 0
63+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
64+ return err
65+ }
66+ }
67+ // The surface under the pointer, the origin's usually, is the first
68+ // target.
69+ return c.dataDragRoute(ctx, c.motionTime)
70+}
71+
72+// dataDragRoute sends the surface under the cursor as the drag target: a
73+// focus when it changed, else a motion.
74+func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
75+ d := c.dataDrag
76+ id, x, y := c.hit(c.cursorX, c.cursorY)
77+ switch {
78+ case id != d.target:
79+ d.target = id
80+ return c.command(ctx, ports.DataDragFocus{ID: id, X: x, Y: y, Time: at})
81+ case id != 0:
82+ return c.command(ctx, ports.DataDragMotion{ID: id, X: x, Y: y, Time: at})
83+ }
84+ return nil
85+}
86+
87+// dataDragMotion follows the cursor: the target gets the motion and the
88+// icon moves with it.
89+func (c *Core) dataDragMotion(ctx context.Context, at time.Duration) error {
90+ if err := c.dataDragRoute(ctx, at); err != nil {
91+ return err
92+ }
93+ if c.dataDrag.icon.visible() {
94+ return c.publish(ctx)
95+ }
96+ return nil
97+}
98+
99+// dataDragButton handles a button event of a data drag: the release of
100+// the drag button drops; the other buttons do nothing, their releases
101+// neither.
102+func (c *Core) dataDragButton(ctx context.Context, v ports.PointerButton) error {
103+ if !v.Pressed {
104+ if v.Button != c.dataDrag.button {
105+ return nil
106+ }
107+ delete(c.buttons, v.Button)
108+ c.dataDrag = nil
109+ if err := c.command(ctx, ports.DataDragDrop{}); err != nil {
110+ return err
111+ }
112+ return c.finishDataDrag(ctx)
113+ }
114+ c.buttons[v.Button] = true
115+ c.swallow[v.Button] = true
116+ return nil
117+}
118+
119+// cancelDataDrag ends the drag on Escape.
120+func (c *Core) cancelDataDrag(ctx context.Context) error {
121+ c.abortDataDrag()
122+ if err := c.command(ctx, ports.DataDragCancel{}); err != nil {
123+ return err
124+ }
125+ return c.finishDataDrag(ctx)
126+}
127+
128+// abortDataDrag ends the drag where it is; the held button's release is
129+// dropped.
130+func (c *Core) abortDataDrag() {
131+ if d := c.dataDrag; d != nil {
132+ c.swallow[d.button] = true
133+ c.dataDrag = nil
134+ }
135+}
136+
137+// finishDataDrag redraws without the icon and points the pointer at what
138+// lies under the cursor.
139+func (c *Core) finishDataDrag(ctx context.Context) error {
140+ if err := c.publish(ctx); err != nil {
141+ return err
142+ }
143+ return c.rehit(ctx)
144+}
145+
146+// dataDragGone drops the target that was unmapped: it gets its leave.
147+func (c *Core) dataDragGone(ctx context.Context, id WindowID) error {
148+ if d := c.dataDrag; d != nil && d.target == id {
149+ d.target = 0
150+ return c.command(ctx, ports.DataDragFocus{Time: c.motionTime})
151+ }
152+ return nil
153+}
154+
155+// setDataDragIcon records the icon geometry wayland measured. It reports
156+// whether the scene changes.
157+func (c *Core) setDataDragIcon(v ports.DataDragIcon) bool {
158+ d := c.dataDrag
159+ if d == nil || d.icon.id == 0 || v.ID != d.icon.id {
160+ return false
161+ }
162+ icon := dataDragIcon{id: v.ID, x: v.X, y: v.Y, w: max(v.W, 0), h: max(v.H, 0)}
163+ if icon == d.icon {
164+ return false
165+ }
166+ d.icon = icon
167+ return true
168+}
169+
170+func (i dataDragIcon) visible() bool { return i.id != 0 && i.w > 0 && i.h > 0 }
171+
172+// dataDragIconOn is the icon as a window of screen sc (index i) when the
173+// cursor is on it.
174+func (c *Core) dataDragIconOn(i int, sc *screen) (ports.SceneWindow, bool) {
175+ d := c.dataDrag
176+ if d == nil || !d.icon.visible() || c.screenAt(c.cursorX, c.cursorY) != i {
177+ return ports.SceneWindow{}, false
178+ }
179+ x, y := int(c.cursorX)-sc.x+d.icon.x, int(c.cursorY)-sc.y+d.icon.y
180+ return ports.SceneWindow{ID: d.icon.id, Rect: Rect{X: x, Y: y, W: d.icon.w, H: d.icon.h}, Popup: true, OverLayers: true}, true
181+}
@@ -0,0 +1,181 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Data drag: a client drag and drop (wl_data_device.start_drag). Wayland
11+// validated the request against the client's press and tells core; core
12+// owns the pointer for the length of the drag, as for a window drag
13+// (drag.go):
14+//
15+// - the origin client gets the pointer leave and no pointer event after;
16+// nobody else gets one: the surface under the pointer is the drag
17+// target, told to wayland as DataDragFocus and DataDragMotion;
18+// - releasing the drag button drops (DataDragDrop), Escape cancels
19+// (DataDragCancel); neither reaches a client;
20+// - scrolling and touchpad gestures go nowhere; the keyboard keeps its
21+// focus, clients read the modifiers that choose the action from it;
22+// - the drag icon, a client surface wayland measures (DataDragIcon), is
23+// drawn at the cursor over everything.
24+//
25+// Layout changes under a still cursor, like an unmapped target, are
26+// resolved at the next motion.
27+
28+// dataDragState is the data drag in progress.
29+type dataDragState struct {
30+ // button is the pointer button that holds the drag.
31+ button uint32
32+ // target is the surface the drag was last routed to (0: none).
33+ target WindowID
34+ icon dataDragIcon
35+}
36+
37+// dataDragIcon is the icon surface: its top left corner relative to the
38+// cursor and its size, logical px. Hidden while W or H is 0.
39+type dataDragIcon struct {
40+ id WindowID
41+ x, y, w, h int
42+}
43+
44+// startDataDrag answers the start_drag wayland validated: it takes the
45+// pointer if a client holds a button, else cancels the drag. A drag
46+// during a window drag or a locked session is refused.
47+func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {
48+ button := c.lastButton
49+ if c.dataDrag != nil || c.drag != nil || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
50+ return c.command(ctx, ports.DataDragCancel{})
51+ }
52+ c.dataDrag = &dataDragState{button: button, icon: dataDragIcon{id: v.Icon}}
53+ // Other held buttons release without a client to tell.
54+ for b := range c.buttons {
55+ if b != button {
56+ c.swallow[b] = true
57+ }
58+ }
59+ c.grab = 0
60+ // The origin gets its pointer leave, as the drag takes the pointer.
61+ if c.pointer != 0 {
62+ c.pointer = 0
63+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
64+ return err
65+ }
66+ }
67+ // The surface under the pointer, the origin's usually, is the first
68+ // target.
69+ return c.dataDragRoute(ctx, c.motionTime)
70+}
71+
72+// dataDragRoute sends the surface under the cursor as the drag target: a
73+// focus when it changed, else a motion.
74+func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
75+ d := c.dataDrag
76+ id, x, y := c.hit(c.cursorX, c.cursorY)
77+ switch {
78+ case id != d.target:
79+ d.target = id
80+ return c.command(ctx, ports.DataDragFocus{ID: id, X: x, Y: y, Time: at})
81+ case id != 0:
82+ return c.command(ctx, ports.DataDragMotion{ID: id, X: x, Y: y, Time: at})
83+ }
84+ return nil
85+}
86+
87+// dataDragMotion follows the cursor: the target gets the motion and the
88+// icon moves with it.
89+func (c *Core) dataDragMotion(ctx context.Context, at time.Duration) error {
90+ if err := c.dataDragRoute(ctx, at); err != nil {
91+ return err
92+ }
93+ if c.dataDrag.icon.visible() {
94+ return c.publish(ctx)
95+ }
96+ return nil
97+}
98+
99+// dataDragButton handles a button event of a data drag: the release of
100+// the drag button drops; the other buttons do nothing, their releases
101+// neither.
102+func (c *Core) dataDragButton(ctx context.Context, v ports.PointerButton) error {
103+ if !v.Pressed {
104+ if v.Button != c.dataDrag.button {
105+ return nil
106+ }
107+ delete(c.buttons, v.Button)
108+ c.dataDrag = nil
109+ if err := c.command(ctx, ports.DataDragDrop{}); err != nil {
110+ return err
111+ }
112+ return c.finishDataDrag(ctx)
113+ }
114+ c.buttons[v.Button] = true
115+ c.swallow[v.Button] = true
116+ return nil
117+}
118+
119+// cancelDataDrag ends the drag on Escape.
120+func (c *Core) cancelDataDrag(ctx context.Context) error {
121+ c.abortDataDrag()
122+ if err := c.command(ctx, ports.DataDragCancel{}); err != nil {
123+ return err
124+ }
125+ return c.finishDataDrag(ctx)
126+}
127+
128+// abortDataDrag ends the drag where it is; the held button's release is
129+// dropped.
130+func (c *Core) abortDataDrag() {
131+ if d := c.dataDrag; d != nil {
132+ c.swallow[d.button] = true
133+ c.dataDrag = nil
134+ }
135+}
136+
137+// finishDataDrag redraws without the icon and points the pointer at what
138+// lies under the cursor.
139+func (c *Core) finishDataDrag(ctx context.Context) error {
140+ if err := c.publish(ctx); err != nil {
141+ return err
142+ }
143+ return c.rehit(ctx)
144+}
145+
146+// dataDragGone drops the target that was unmapped: it gets its leave.
147+func (c *Core) dataDragGone(ctx context.Context, id WindowID) error {
148+ if d := c.dataDrag; d != nil && d.target == id {
149+ d.target = 0
150+ return c.command(ctx, ports.DataDragFocus{Time: c.motionTime})
151+ }
152+ return nil
153+}
154+
155+// setDataDragIcon records the icon geometry wayland measured. It reports
156+// whether the scene changes.
157+func (c *Core) setDataDragIcon(v ports.DataDragIcon) bool {
158+ d := c.dataDrag
159+ if d == nil || d.icon.id == 0 || v.ID != d.icon.id {
160+ return false
161+ }
162+ icon := dataDragIcon{id: v.ID, x: v.X, y: v.Y, w: max(v.W, 0), h: max(v.H, 0)}
163+ if icon == d.icon {
164+ return false
165+ }
166+ d.icon = icon
167+ return true
168+}
169+
170+func (i dataDragIcon) visible() bool { return i.id != 0 && i.w > 0 && i.h > 0 }
171+
172+// dataDragIconOn is the icon as a window of screen sc (index i) when the
173+// cursor is on it.
174+func (c *Core) dataDragIconOn(i int, sc *screen) (ports.SceneWindow, bool) {
175+ d := c.dataDrag
176+ if d == nil || !d.icon.visible() || c.screenAt(c.cursorX, c.cursorY) != i {
177+ return ports.SceneWindow{}, false
178+ }
179+ x, y := int(c.cursorX)-sc.x+d.icon.x, int(c.cursorY)-sc.y+d.icon.y
180+ return ports.SceneWindow{ID: d.icon.id, Rect: Rect{X: x, Y: y, W: d.icon.w, H: d.icon.h}, Popup: true, OverLayers: true}, true
181+}
A internal/core/datadrag_test.go +465 -0
@@ -0,0 +1,465 @@
1+package core_test
2+
3+import (
4+ "slices"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+const dndIcon ports.WindowID = 900
12+
13+// dndRig is a drag rig with tiles 1 and 2 side by side and the pointer
14+// on 1 with its button held, as a client that is about to start_drag.
15+type dndRig struct {
16+ *dragRig
17+ one, two ports.Rect
18+}
19+
20+func startDnd(t *testing.T, outs ...ports.OutputInfo) *dndRig {
21+ t.Helper()
22+ if len(outs) == 0 {
23+ outs = []ports.OutputInfo{dragOut}
24+ }
25+ r := startDragRig(t, nil, outs...)
26+ r.client <- ports.WindowMapped{ID: 1}
27+ r.client <- ports.WindowMapped{ID: 2}
28+ set := r.settle(t)
29+ d := &dndRig{dragRig: r, one: dragRect(t, set, outs[0].Name, 1), two: dragRect(t, set, outs[0].Name, 2)}
30+ r.moveTo(float64(d.one.X+20), float64(d.one.Y+30), 1)
31+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
32+ r.settle(t)
33+ r.drain()
34+ return d
35+}
36+
37+// dataDragCommands keeps what the data drag sends and the pointer events
38+// no client may see during it.
39+func dataDragCommands(cmds []ports.ClientCommand) []ports.ClientCommand {
40+ var out []ports.ClientCommand
41+ for _, v := range cmds {
42+ switch v.(type) {
43+ case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel,
44+ ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo,
45+ ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
46+ out = append(out, v)
47+ }
48+ }
49+ return out
50+}
51+
52+func (d *dndRig) start(t *testing.T, icon ports.WindowID) []ports.ClientCommand {
53+ t.Helper()
54+ d.client <- ports.DataDragStarted{Icon: icon}
55+ d.settle(t)
56+ return dataDragCommands(d.drain())
57+}
58+
59+func wantCommands(t *testing.T, got []ports.ClientCommand, want ...ports.ClientCommand) {
60+ t.Helper()
61+ if !slices.Equal(got, want) {
62+ t.Fatalf("commands %#v, want %#v", got, want)
63+ }
64+}
65+
66+func TestDataDragRoutesToSurfaceUnderPointer(t *testing.T) {
67+ d := startDnd(t)
68+ // The origin loses the pointer and is the first target.
69+ wantCommands(t, d.start(t, 0),
70+ ports.PointerFocus{},
71+ ports.DataDragFocus{ID: 1, X: 20, Y: 30, Time: time.Millisecond})
72+ // Motion over the same surface.
73+ d.moveTo(float64(d.one.X+25), float64(d.one.Y+35), 2)
74+ d.settle(t)
75+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragMotion{ID: 1, X: 25, Y: 35, Time: 2 * time.Millisecond})
76+ // Over the other window: it is the new target.
77+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
78+ d.settle(t)
79+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{ID: 2, X: 5, Y: 6, Time: 3 * time.Millisecond})
80+ d.moveTo(float64(d.two.X+7), float64(d.two.Y+8), 4)
81+ d.settle(t)
82+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragMotion{ID: 2, X: 7, Y: 8, Time: 4 * time.Millisecond})
83+ // Over nothing: no target.
84+ d.moveTo(float64(d.two.X+d.two.W+5), float64(d.two.Y+8), 5)
85+ d.settle(t)
86+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{Time: 5 * time.Millisecond})
87+ d.moveTo(float64(d.two.X+d.two.W+6), float64(d.two.Y+8), 6)
88+ d.settle(t)
89+ wantCommands(t, dataDragCommands(d.drain()))
90+}
91+
92+func TestDataDragDropsOnRelease(t *testing.T) {
93+ d := startDnd(t)
94+ d.start(t, 0)
95+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
96+ d.in(ports.PointerButton{Button: 0x110, Time: 4 * time.Millisecond})
97+ d.settle(t)
98+ // The release is the drop; the pointer then points at the window under
99+ // the cursor, not at the origin that grabbed it.
100+ wantCommands(t, dataDragCommands(d.drain()),
101+ ports.DataDragFocus{ID: 2, X: 5, Y: 6, Time: 3 * time.Millisecond},
102+ ports.DataDragDrop{},
103+ ports.PointerFocus{ID: 2, X: 5, Y: 6})
104+ // The drag is over: motion is plain pointer motion again.
105+ d.moveTo(float64(d.two.X+9), float64(d.two.Y+9), 5)
106+ d.settle(t)
107+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerMotionTo{ID: 2, X: 9, Y: 9, Time: 5 * time.Millisecond})
108+ // The next press is an ordinary click.
109+ d.in(ports.PointerButton{Button: 0x110, Pressed: true})
110+ d.settle(t)
111+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerButtonTo{ID: 2, Button: 0x110, Pressed: true})
112+}
113+
114+func TestDataDragSendsNothingToClients(t *testing.T) {
115+ d := startDnd(t)
116+ d.start(t, 0)
117+ d.in(ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}})
118+ d.in(ports.PinchBegin{Fingers: 2, Time: time.Second})
119+ d.in(ports.PinchUpdate{Scale: 2, Time: time.Second})
120+ d.in(ports.PinchEnd{Time: time.Second})
121+ d.in(ports.HoldBegin{Fingers: 2, Time: time.Second})
122+ d.in(ports.HoldEnd{Time: time.Second})
123+ d.in(ports.SwipeBegin{Fingers: 2, Time: time.Second})
124+ d.in(ports.SwipeEnd{Time: time.Second})
125+ // Other buttons neither press nor release anywhere.
126+ d.in(ports.PointerButton{Button: 0x111, Pressed: true})
127+ d.in(ports.PointerButton{Button: 0x111})
128+ d.settle(t)
129+ for _, v := range dataDragCommands(d.drain()) {
130+ switch v.(type) {
131+ case ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.PointerMotionTo, ports.DataDragDrop, ports.DataDragCancel:
132+ t.Fatalf("sent %#v", v)
133+ }
134+ }
135+ // Still dragging: the drag button's release drops.
136+ d.in(ports.PointerButton{Button: 0x110})
137+ d.settle(t)
138+ if got := dataDragCommands(d.drain()); len(got) == 0 || got[0] != (ports.DataDragDrop{}) {
139+ t.Fatalf("commands %#v", got)
140+ }
141+}
142+
143+func TestDataDragKeyboardKeepsForwarding(t *testing.T) {
144+ d := startDnd(t)
145+ d.start(t, 0)
146+ d.in(ports.KeyEvent{Keysym: "Shift_L", Keycode: 42, Pressed: true, Mods: ports.ModShift})
147+ d.in(ports.KeyEvent{Keysym: "Shift_L", Keycode: 42})
148+ d.settle(t)
149+ n := 0
150+ for _, v := range d.drain() {
151+ if _, ok := v.(ports.ForwardKey); ok {
152+ n++
153+ }
154+ }
155+ if n != 2 {
156+ t.Fatalf("%d keys forwarded, want 2", n)
157+ }
158+}
159+
160+func TestDataDragEscapeCancels(t *testing.T) {
161+ d := startDnd(t)
162+ d.start(t, 0)
163+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
164+ d.settle(t)
165+ d.drain()
166+ d.in(ports.KeyEvent{Keysym: "Escape", Keycode: 1, Pressed: true})
167+ d.in(ports.KeyEvent{Keysym: "Escape", Keycode: 1})
168+ d.in(ports.PointerButton{Button: 0x110})
169+ d.settle(t)
170+ cmds := d.drain()
171+ wantCommands(t, dataDragCommands(cmds),
172+ ports.DataDragCancel{},
173+ ports.PointerFocus{ID: 2, X: 5, Y: 6})
174+ for _, v := range cmds {
175+ if k, ok := v.(ports.ForwardKey); ok {
176+ t.Fatalf("client saw %#v", k)
177+ }
178+ }
179+ // The press that started the drag is over.
180+ d.moveTo(float64(d.two.X+9), float64(d.two.Y+9), 5)
181+ d.settle(t)
182+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerMotionTo{ID: 2, X: 9, Y: 9, Time: 5 * time.Millisecond})
183+}
184+
185+func TestDataDragEndedByWayland(t *testing.T) {
186+ d := startDnd(t)
187+ d.start(t, dndIcon)
188+ d.client <- ports.DataDragIcon{ID: dndIcon, X: 1, Y: 2, W: 16, H: 16}
189+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
190+ d.settle(t)
191+ d.drain()
192+ // The source died: no command, the icon goes and the pointer is
193+ // pointed at the window under the cursor.
194+ d.client <- ports.DataDragEnded{}
195+ set := d.settle(t)
196+ if _, ok := windowIn(set, "A", dndIcon); ok {
197+ t.Fatal("icon still drawn")
198+ }
199+ wantCommands(t, dataDragCommands(d.drain()),
200+ ports.PointerFocus{ID: 2, X: 5, Y: 6})
201+ // The held button's release reaches nobody.
202+ d.in(ports.PointerButton{Button: 0x110})
203+ d.settle(t)
204+ wantCommands(t, dataDragCommands(d.drain()))
205+ // An end with no drag is ignored.
206+ d.client <- ports.DataDragEnded{}
207+ d.settle(t)
208+ wantCommands(t, dataDragCommands(d.drain()))
209+}
210+
211+func TestDataDragRefused(t *testing.T) {
212+ t.Run("no press", func(t *testing.T) {
213+ d := startDnd(t)
214+ d.in(ports.PointerButton{Button: 0x110})
215+ d.settle(t)
216+ d.drain()
217+ wantCommands(t, d.start(t, 0), ports.DataDragCancel{})
218+ // No drag started: motion is plain.
219+ d.moveTo(float64(d.one.X+21), float64(d.one.Y+30), 2)
220+ d.settle(t)
221+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerMotionTo{ID: 1, X: 21, Y: 30, Time: 2 * time.Millisecond})
222+ })
223+ t.Run("window drag", func(t *testing.T) {
224+ r := startDragRig(t, nil, dragOut)
225+ r.client <- ports.WindowMapped{ID: 1}
226+ r.client <- ports.WindowMapped{ID: 2}
227+ set := r.settle(t)
228+ one := dragRect(t, set, "A", 1)
229+ r.moveTo(float64(one.X+20), float64(one.Y+30), 1)
230+ r.settle(t)
231+ r.press(t, 0x110, ports.ModSuper)
232+ r.settle(t)
233+ r.drain()
234+ r.client <- ports.DataDragStarted{}
235+ r.settle(t)
236+ wantCommands(t, r.drain(), ports.DataDragCancel{})
237+ // The window drag goes on.
238+ r.moveTo(float64(one.X+200), float64(one.Y+30), 2)
239+ r.in(ports.PointerButton{Button: 0x110})
240+ r.settle(t)
241+ wantCommands(t, dataDragCommands(r.drain()), ports.PointerFocus{ID: 1, X: 200, Y: 30})
242+ })
243+ t.Run("twice", func(t *testing.T) {
244+ d := startDnd(t)
245+ d.start(t, 0)
246+ wantCommands(t, d.start(t, 0), ports.DataDragCancel{})
247+ })
248+ t.Run("locked", func(t *testing.T) {
249+ d := startDnd(t)
250+ locked := ports.SecurityState{Generation: 1, Protected: true}
251+ d.epoch.Store(locked)
252+ d.client <- ports.SessionLockChanged{State: locked}
253+ d.settle(t)
254+ d.drain()
255+ d.client <- ports.DataDragStarted{}
256+ d.settle(t)
257+ var got []ports.ClientCommand
258+ for _, v := range d.drain() {
259+ switch v.(type) {
260+ case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
261+ got = append(got, v)
262+ }
263+ }
264+ wantCommands(t, got, ports.DataDragCancel{})
265+ })
266+}
267+
268+func TestDataDragCommandsAreSecurityWrapped(t *testing.T) {
269+ d := startDnd(t)
270+ d.client <- ports.DataDragStarted{}
271+ d.settle(t)
272+ n := 0
273+ for {
274+ select {
275+ case v := <-d.commands:
276+ s, ok := v.(ports.SecurityCommand)
277+ if !ok {
278+ t.Fatalf("unwrapped %#v", v)
279+ }
280+ if _, ok := s.Command.(ports.DataDragFocus); ok {
281+ n++
282+ }
283+ continue
284+ default:
285+ }
286+ break
287+ }
288+ if n != 1 {
289+ t.Fatalf("%d DataDragFocus", n)
290+ }
291+}
292+
293+func TestDataDragLockCancelsIt(t *testing.T) {
294+ d := startDnd(t)
295+ d.start(t, dndIcon)
296+ d.client <- ports.DataDragIcon{ID: dndIcon, W: 16, H: 16}
297+ d.settle(t)
298+ locked := ports.SecurityState{Generation: 1, Protected: true}
299+ d.epoch.Store(locked)
300+ d.client <- ports.SessionLockChanged{State: locked, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 1000, Height: 600}}}
301+ set := d.settle(t)
302+ if _, ok := windowIn(set, "A", dndIcon); ok {
303+ t.Fatal("icon drawn while locked")
304+ }
305+ d.drain()
306+ d.in(ports.PointerMotion{X: 100, Y: 100, Time: time.Second})
307+ d.settle(t)
308+ for _, v := range d.drain() {
309+ switch v.(type) {
310+ case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop:
311+ t.Fatalf("data drag alive while locked: %#v", v)
312+ }
313+ }
314+ // After the unlock, the old drag is gone: plain pointer events.
315+ unlocked := ports.SecurityState{Generation: 2}
316+ d.epoch.Store(unlocked)
317+ d.client <- ports.SessionLockChanged{State: unlocked}
318+ d.settle(t)
319+ d.drain()
320+ d.moveTo(float64(d.one.X+21), float64(d.one.Y+30), 9)
321+ d.settle(t)
322+ got := dataDragCommands(d.drain())
323+ wantCommands(t, got, ports.PointerFocus{ID: 1, X: 21, Y: 30}, ports.PointerMotionTo{ID: 1, X: 21, Y: 30, Time: 9 * time.Millisecond})
324+}
325+
326+func TestDataDragTargetUnmapped(t *testing.T) {
327+ d := startDnd(t)
328+ d.start(t, 0)
329+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
330+ d.settle(t)
331+ d.drain()
332+ d.client <- ports.WindowUnmapped{ID: 2}
333+ d.settle(t)
334+ cmds := dataDragCommands(d.drain())
335+ if len(cmds) == 0 || cmds[0] != (ports.DataDragFocus{Time: 3 * time.Millisecond}) {
336+ t.Fatalf("commands %#v", cmds)
337+ }
338+}
339+
340+func TestDataDragIconIsLastWindowAtCursor(t *testing.T) {
341+ d := startDnd(t)
342+ d.start(t, dndIcon)
343+ // Not drawn until wayland measured it.
344+ if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
345+ t.Fatal("icon drawn before its size")
346+ }
347+ cx, cy := d.one.X+20, d.one.Y+30
348+ d.client <- ports.DataDragIcon{ID: dndIcon, X: -4, Y: -6, W: 32, H: 24}
349+ set := d.settle(t)
350+ s := dragScene(set, "A")
351+ last := s.Windows[len(s.Windows)-1]
352+ want := ports.SceneWindow{ID: dndIcon, Rect: ports.Rect{X: cx - 4, Y: cy - 6, W: 32, H: 24}, Popup: true, OverLayers: true}
353+ if last != want {
354+ t.Fatalf("last window %+v, want %+v", last, want)
355+ }
356+ // It follows the cursor; the scene changes.
357+ d.moveTo(float64(cx+100), float64(cy+50), 2)
358+ moved := dragScene(d.settle(t), "A")
359+ if moved.SameAs(s) || moved.Seq == s.Seq {
360+ t.Fatal("scene did not change with the icon")
361+ }
362+ if got := moved.Windows[len(moved.Windows)-1]; got.Rect.X != cx+96 || got.Rect.Y != cy+44 || got.ID != dndIcon {
363+ t.Fatalf("moved icon %+v", got)
364+ }
365+ // Under the icon, the surface below is still the target.
366+ d.drain()
367+ d.moveTo(float64(cx+101), float64(cy+50), 3)
368+ d.settle(t)
369+ for _, v := range d.drain() {
370+ if f, ok := v.(ports.DataDragFocus); ok && f.ID == dndIcon {
371+ t.Fatalf("icon was targeted: %#v", f)
372+ }
373+ }
374+ // A commit that changes the offset moves it; a zero size hides it.
375+ d.client <- ports.DataDragIcon{ID: dndIcon, X: 0, Y: 0, W: 32, H: 24}
376+ if got := dragScene(d.settle(t), "A"); got.Windows[len(got.Windows)-1].Rect.X != cx+101 {
377+ t.Fatalf("offset ignored: %+v", got.Windows[len(got.Windows)-1])
378+ }
379+ d.client <- ports.DataDragIcon{ID: dndIcon}
380+ if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
381+ t.Fatal("hidden icon drawn")
382+ }
383+ // Geometry of another surface is not the icon's.
384+ d.client <- ports.DataDragIcon{ID: dndIcon + 1, W: 10, H: 10}
385+ if _, ok := windowIn(d.settle(t), "A", dndIcon+1); ok {
386+ t.Fatal("foreign icon drawn")
387+ }
388+ // The drop removes it.
389+ d.client <- ports.DataDragIcon{ID: dndIcon, W: 32, H: 24}
390+ d.settle(t)
391+ d.in(ports.PointerButton{Button: 0x110})
392+ set = d.settle(t)
393+ if _, ok := windowIn(set, "A", dndIcon); ok {
394+ t.Fatal("icon drawn after the drop")
395+ }
396+}
397+
398+func TestDataDragIconOnScreenUnderCursor(t *testing.T) {
399+ other := ports.OutputInfo{Name: "B", Width: 800, Height: 600}
400+ d := startDnd(t, dragOut, other)
401+ d.start(t, dndIcon)
402+ d.client <- ports.DataDragIcon{ID: dndIcon, X: 2, Y: 3, W: 10, H: 10}
403+ d.moveTo(1000+100, 50, 2)
404+ set := d.settle(t)
405+ if _, ok := windowIn(set, "A", dndIcon); ok {
406+ t.Fatal("icon on the other screen")
407+ }
408+ w, ok := windowIn(set, "B", dndIcon)
409+ if !ok {
410+ t.Fatal("no icon on B")
411+ }
412+ if w.Rect != (ports.Rect{X: 102, Y: 53, W: 10, H: 10}) {
413+ t.Fatalf("icon %+v", w.Rect)
414+ }
415+}
416+
417+func TestDataDragNoIconIgnoresIconEvents(t *testing.T) {
418+ d := startDnd(t)
419+ d.start(t, 0)
420+ d.client <- ports.DataDragIcon{ID: dndIcon, W: 10, H: 10}
421+ if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
422+ t.Fatal("icon drawn for a drag without one")
423+ }
424+ // Nor with no drag at all.
425+ d.in(ports.PointerButton{Button: 0x110})
426+ d.settle(t)
427+ d.client <- ports.DataDragIcon{ID: dndIcon, W: 10, H: 10}
428+ if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
429+ t.Fatal("icon drawn without a drag")
430+ }
431+}
432+
433+func TestDataDragFocusScreenFollowsPointer(t *testing.T) {
434+ r := startDragRig(t, nil, dragOut, ports.OutputInfo{Name: "B", Width: 1000, Height: 600})
435+ r.client <- ports.WindowMapped{ID: 1}
436+ r.settle(t)
437+ r.moveTo(1500, 300, 1)
438+ r.settle(t)
439+ r.client <- ports.WindowMapped{ID: 3}
440+ set := r.settle(t)
441+ one := dragRect(t, set, "A", 1)
442+ r.moveTo(float64(one.X+20), float64(one.Y+30), 2)
443+ set = r.settle(t)
444+ if w, _ := windowIn(set, "A", 1); !w.Focused {
445+ t.Fatal("1 not focused")
446+ }
447+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
448+ r.settle(t)
449+ r.client <- ports.DataDragStarted{}
450+ r.moveTo(1500, 300, 3)
451+ set = r.settle(t)
452+ if w, _ := windowIn(set, "B", 3); !w.Focused {
453+ t.Fatal("the screen under the drag did not take the focus")
454+ }
455+ three := dragRect(t, set, "B", 3)
456+ var focus []ports.DataDragFocus
457+ for _, v := range r.drain() {
458+ if f, ok := v.(ports.DataDragFocus); ok {
459+ focus = append(focus, f)
460+ }
461+ }
462+ if n := len(focus); n != 2 || focus[1].ID != 3 || focus[1].X != float64(1500-1000-three.X) {
463+ t.Fatalf("focus %+v", focus)
464+ }
465+}
@@ -0,0 +1,465 @@
1+package core_test
2+
3+import (
4+ "slices"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+const dndIcon ports.WindowID = 900
12+
13+// dndRig is a drag rig with tiles 1 and 2 side by side and the pointer
14+// on 1 with its button held, as a client that is about to start_drag.
15+type dndRig struct {
16+ *dragRig
17+ one, two ports.Rect
18+}
19+
20+func startDnd(t *testing.T, outs ...ports.OutputInfo) *dndRig {
21+ t.Helper()
22+ if len(outs) == 0 {
23+ outs = []ports.OutputInfo{dragOut}
24+ }
25+ r := startDragRig(t, nil, outs...)
26+ r.client <- ports.WindowMapped{ID: 1}
27+ r.client <- ports.WindowMapped{ID: 2}
28+ set := r.settle(t)
29+ d := &dndRig{dragRig: r, one: dragRect(t, set, outs[0].Name, 1), two: dragRect(t, set, outs[0].Name, 2)}
30+ r.moveTo(float64(d.one.X+20), float64(d.one.Y+30), 1)
31+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
32+ r.settle(t)
33+ r.drain()
34+ return d
35+}
36+
37+// dataDragCommands keeps what the data drag sends and the pointer events
38+// no client may see during it.
39+func dataDragCommands(cmds []ports.ClientCommand) []ports.ClientCommand {
40+ var out []ports.ClientCommand
41+ for _, v := range cmds {
42+ switch v.(type) {
43+ case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel,
44+ ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo,
45+ ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo:
46+ out = append(out, v)
47+ }
48+ }
49+ return out
50+}
51+
52+func (d *dndRig) start(t *testing.T, icon ports.WindowID) []ports.ClientCommand {
53+ t.Helper()
54+ d.client <- ports.DataDragStarted{Icon: icon}
55+ d.settle(t)
56+ return dataDragCommands(d.drain())
57+}
58+
59+func wantCommands(t *testing.T, got []ports.ClientCommand, want ...ports.ClientCommand) {
60+ t.Helper()
61+ if !slices.Equal(got, want) {
62+ t.Fatalf("commands %#v, want %#v", got, want)
63+ }
64+}
65+
66+func TestDataDragRoutesToSurfaceUnderPointer(t *testing.T) {
67+ d := startDnd(t)
68+ // The origin loses the pointer and is the first target.
69+ wantCommands(t, d.start(t, 0),
70+ ports.PointerFocus{},
71+ ports.DataDragFocus{ID: 1, X: 20, Y: 30, Time: time.Millisecond})
72+ // Motion over the same surface.
73+ d.moveTo(float64(d.one.X+25), float64(d.one.Y+35), 2)
74+ d.settle(t)
75+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragMotion{ID: 1, X: 25, Y: 35, Time: 2 * time.Millisecond})
76+ // Over the other window: it is the new target.
77+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
78+ d.settle(t)
79+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{ID: 2, X: 5, Y: 6, Time: 3 * time.Millisecond})
80+ d.moveTo(float64(d.two.X+7), float64(d.two.Y+8), 4)
81+ d.settle(t)
82+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragMotion{ID: 2, X: 7, Y: 8, Time: 4 * time.Millisecond})
83+ // Over nothing: no target.
84+ d.moveTo(float64(d.two.X+d.two.W+5), float64(d.two.Y+8), 5)
85+ d.settle(t)
86+ wantCommands(t, dataDragCommands(d.drain()), ports.DataDragFocus{Time: 5 * time.Millisecond})
87+ d.moveTo(float64(d.two.X+d.two.W+6), float64(d.two.Y+8), 6)
88+ d.settle(t)
89+ wantCommands(t, dataDragCommands(d.drain()))
90+}
91+
92+func TestDataDragDropsOnRelease(t *testing.T) {
93+ d := startDnd(t)
94+ d.start(t, 0)
95+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
96+ d.in(ports.PointerButton{Button: 0x110, Time: 4 * time.Millisecond})
97+ d.settle(t)
98+ // The release is the drop; the pointer then points at the window under
99+ // the cursor, not at the origin that grabbed it.
100+ wantCommands(t, dataDragCommands(d.drain()),
101+ ports.DataDragFocus{ID: 2, X: 5, Y: 6, Time: 3 * time.Millisecond},
102+ ports.DataDragDrop{},
103+ ports.PointerFocus{ID: 2, X: 5, Y: 6})
104+ // The drag is over: motion is plain pointer motion again.
105+ d.moveTo(float64(d.two.X+9), float64(d.two.Y+9), 5)
106+ d.settle(t)
107+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerMotionTo{ID: 2, X: 9, Y: 9, Time: 5 * time.Millisecond})
108+ // The next press is an ordinary click.
109+ d.in(ports.PointerButton{Button: 0x110, Pressed: true})
110+ d.settle(t)
111+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerButtonTo{ID: 2, Button: 0x110, Pressed: true})
112+}
113+
114+func TestDataDragSendsNothingToClients(t *testing.T) {
115+ d := startDnd(t)
116+ d.start(t, 0)
117+ d.in(ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}})
118+ d.in(ports.PinchBegin{Fingers: 2, Time: time.Second})
119+ d.in(ports.PinchUpdate{Scale: 2, Time: time.Second})
120+ d.in(ports.PinchEnd{Time: time.Second})
121+ d.in(ports.HoldBegin{Fingers: 2, Time: time.Second})
122+ d.in(ports.HoldEnd{Time: time.Second})
123+ d.in(ports.SwipeBegin{Fingers: 2, Time: time.Second})
124+ d.in(ports.SwipeEnd{Time: time.Second})
125+ // Other buttons neither press nor release anywhere.
126+ d.in(ports.PointerButton{Button: 0x111, Pressed: true})
127+ d.in(ports.PointerButton{Button: 0x111})
128+ d.settle(t)
129+ for _, v := range dataDragCommands(d.drain()) {
130+ switch v.(type) {
131+ case ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.PointerMotionTo, ports.DataDragDrop, ports.DataDragCancel:
132+ t.Fatalf("sent %#v", v)
133+ }
134+ }
135+ // Still dragging: the drag button's release drops.
136+ d.in(ports.PointerButton{Button: 0x110})
137+ d.settle(t)
138+ if got := dataDragCommands(d.drain()); len(got) == 0 || got[0] != (ports.DataDragDrop{}) {
139+ t.Fatalf("commands %#v", got)
140+ }
141+}
142+
143+func TestDataDragKeyboardKeepsForwarding(t *testing.T) {
144+ d := startDnd(t)
145+ d.start(t, 0)
146+ d.in(ports.KeyEvent{Keysym: "Shift_L", Keycode: 42, Pressed: true, Mods: ports.ModShift})
147+ d.in(ports.KeyEvent{Keysym: "Shift_L", Keycode: 42})
148+ d.settle(t)
149+ n := 0
150+ for _, v := range d.drain() {
151+ if _, ok := v.(ports.ForwardKey); ok {
152+ n++
153+ }
154+ }
155+ if n != 2 {
156+ t.Fatalf("%d keys forwarded, want 2", n)
157+ }
158+}
159+
160+func TestDataDragEscapeCancels(t *testing.T) {
161+ d := startDnd(t)
162+ d.start(t, 0)
163+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
164+ d.settle(t)
165+ d.drain()
166+ d.in(ports.KeyEvent{Keysym: "Escape", Keycode: 1, Pressed: true})
167+ d.in(ports.KeyEvent{Keysym: "Escape", Keycode: 1})
168+ d.in(ports.PointerButton{Button: 0x110})
169+ d.settle(t)
170+ cmds := d.drain()
171+ wantCommands(t, dataDragCommands(cmds),
172+ ports.DataDragCancel{},
173+ ports.PointerFocus{ID: 2, X: 5, Y: 6})
174+ for _, v := range cmds {
175+ if k, ok := v.(ports.ForwardKey); ok {
176+ t.Fatalf("client saw %#v", k)
177+ }
178+ }
179+ // The press that started the drag is over.
180+ d.moveTo(float64(d.two.X+9), float64(d.two.Y+9), 5)
181+ d.settle(t)
182+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerMotionTo{ID: 2, X: 9, Y: 9, Time: 5 * time.Millisecond})
183+}
184+
185+func TestDataDragEndedByWayland(t *testing.T) {
186+ d := startDnd(t)
187+ d.start(t, dndIcon)
188+ d.client <- ports.DataDragIcon{ID: dndIcon, X: 1, Y: 2, W: 16, H: 16}
189+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
190+ d.settle(t)
191+ d.drain()
192+ // The source died: no command, the icon goes and the pointer is
193+ // pointed at the window under the cursor.
194+ d.client <- ports.DataDragEnded{}
195+ set := d.settle(t)
196+ if _, ok := windowIn(set, "A", dndIcon); ok {
197+ t.Fatal("icon still drawn")
198+ }
199+ wantCommands(t, dataDragCommands(d.drain()),
200+ ports.PointerFocus{ID: 2, X: 5, Y: 6})
201+ // The held button's release reaches nobody.
202+ d.in(ports.PointerButton{Button: 0x110})
203+ d.settle(t)
204+ wantCommands(t, dataDragCommands(d.drain()))
205+ // An end with no drag is ignored.
206+ d.client <- ports.DataDragEnded{}
207+ d.settle(t)
208+ wantCommands(t, dataDragCommands(d.drain()))
209+}
210+
211+func TestDataDragRefused(t *testing.T) {
212+ t.Run("no press", func(t *testing.T) {
213+ d := startDnd(t)
214+ d.in(ports.PointerButton{Button: 0x110})
215+ d.settle(t)
216+ d.drain()
217+ wantCommands(t, d.start(t, 0), ports.DataDragCancel{})
218+ // No drag started: motion is plain.
219+ d.moveTo(float64(d.one.X+21), float64(d.one.Y+30), 2)
220+ d.settle(t)
221+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerMotionTo{ID: 1, X: 21, Y: 30, Time: 2 * time.Millisecond})
222+ })
223+ t.Run("window drag", func(t *testing.T) {
224+ r := startDragRig(t, nil, dragOut)
225+ r.client <- ports.WindowMapped{ID: 1}
226+ r.client <- ports.WindowMapped{ID: 2}
227+ set := r.settle(t)
228+ one := dragRect(t, set, "A", 1)
229+ r.moveTo(float64(one.X+20), float64(one.Y+30), 1)
230+ r.settle(t)
231+ r.press(t, 0x110, ports.ModSuper)
232+ r.settle(t)
233+ r.drain()
234+ r.client <- ports.DataDragStarted{}
235+ r.settle(t)
236+ wantCommands(t, r.drain(), ports.DataDragCancel{})
237+ // The window drag goes on.
238+ r.moveTo(float64(one.X+200), float64(one.Y+30), 2)
239+ r.in(ports.PointerButton{Button: 0x110})
240+ r.settle(t)
241+ wantCommands(t, dataDragCommands(r.drain()), ports.PointerFocus{ID: 1, X: 200, Y: 30})
242+ })
243+ t.Run("twice", func(t *testing.T) {
244+ d := startDnd(t)
245+ d.start(t, 0)
246+ wantCommands(t, d.start(t, 0), ports.DataDragCancel{})
247+ })
248+ t.Run("locked", func(t *testing.T) {
249+ d := startDnd(t)
250+ locked := ports.SecurityState{Generation: 1, Protected: true}
251+ d.epoch.Store(locked)
252+ d.client <- ports.SessionLockChanged{State: locked}
253+ d.settle(t)
254+ d.drain()
255+ d.client <- ports.DataDragStarted{}
256+ d.settle(t)
257+ var got []ports.ClientCommand
258+ for _, v := range d.drain() {
259+ switch v.(type) {
260+ case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
261+ got = append(got, v)
262+ }
263+ }
264+ wantCommands(t, got, ports.DataDragCancel{})
265+ })
266+}
267+
268+func TestDataDragCommandsAreSecurityWrapped(t *testing.T) {
269+ d := startDnd(t)
270+ d.client <- ports.DataDragStarted{}
271+ d.settle(t)
272+ n := 0
273+ for {
274+ select {
275+ case v := <-d.commands:
276+ s, ok := v.(ports.SecurityCommand)
277+ if !ok {
278+ t.Fatalf("unwrapped %#v", v)
279+ }
280+ if _, ok := s.Command.(ports.DataDragFocus); ok {
281+ n++
282+ }
283+ continue
284+ default:
285+ }
286+ break
287+ }
288+ if n != 1 {
289+ t.Fatalf("%d DataDragFocus", n)
290+ }
291+}
292+
293+func TestDataDragLockCancelsIt(t *testing.T) {
294+ d := startDnd(t)
295+ d.start(t, dndIcon)
296+ d.client <- ports.DataDragIcon{ID: dndIcon, W: 16, H: 16}
297+ d.settle(t)
298+ locked := ports.SecurityState{Generation: 1, Protected: true}
299+ d.epoch.Store(locked)
300+ d.client <- ports.SessionLockChanged{State: locked, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 1000, Height: 600}}}
301+ set := d.settle(t)
302+ if _, ok := windowIn(set, "A", dndIcon); ok {
303+ t.Fatal("icon drawn while locked")
304+ }
305+ d.drain()
306+ d.in(ports.PointerMotion{X: 100, Y: 100, Time: time.Second})
307+ d.settle(t)
308+ for _, v := range d.drain() {
309+ switch v.(type) {
310+ case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop:
311+ t.Fatalf("data drag alive while locked: %#v", v)
312+ }
313+ }
314+ // After the unlock, the old drag is gone: plain pointer events.
315+ unlocked := ports.SecurityState{Generation: 2}
316+ d.epoch.Store(unlocked)
317+ d.client <- ports.SessionLockChanged{State: unlocked}
318+ d.settle(t)
319+ d.drain()
320+ d.moveTo(float64(d.one.X+21), float64(d.one.Y+30), 9)
321+ d.settle(t)
322+ got := dataDragCommands(d.drain())
323+ wantCommands(t, got, ports.PointerFocus{ID: 1, X: 21, Y: 30}, ports.PointerMotionTo{ID: 1, X: 21, Y: 30, Time: 9 * time.Millisecond})
324+}
325+
326+func TestDataDragTargetUnmapped(t *testing.T) {
327+ d := startDnd(t)
328+ d.start(t, 0)
329+ d.moveTo(float64(d.two.X+5), float64(d.two.Y+6), 3)
330+ d.settle(t)
331+ d.drain()
332+ d.client <- ports.WindowUnmapped{ID: 2}
333+ d.settle(t)
334+ cmds := dataDragCommands(d.drain())
335+ if len(cmds) == 0 || cmds[0] != (ports.DataDragFocus{Time: 3 * time.Millisecond}) {
336+ t.Fatalf("commands %#v", cmds)
337+ }
338+}
339+
340+func TestDataDragIconIsLastWindowAtCursor(t *testing.T) {
341+ d := startDnd(t)
342+ d.start(t, dndIcon)
343+ // Not drawn until wayland measured it.
344+ if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
345+ t.Fatal("icon drawn before its size")
346+ }
347+ cx, cy := d.one.X+20, d.one.Y+30
348+ d.client <- ports.DataDragIcon{ID: dndIcon, X: -4, Y: -6, W: 32, H: 24}
349+ set := d.settle(t)
350+ s := dragScene(set, "A")
351+ last := s.Windows[len(s.Windows)-1]
352+ want := ports.SceneWindow{ID: dndIcon, Rect: ports.Rect{X: cx - 4, Y: cy - 6, W: 32, H: 24}, Popup: true, OverLayers: true}
353+ if last != want {
354+ t.Fatalf("last window %+v, want %+v", last, want)
355+ }
356+ // It follows the cursor; the scene changes.
357+ d.moveTo(float64(cx+100), float64(cy+50), 2)
358+ moved := dragScene(d.settle(t), "A")
359+ if moved.SameAs(s) || moved.Seq == s.Seq {
360+ t.Fatal("scene did not change with the icon")
361+ }
362+ if got := moved.Windows[len(moved.Windows)-1]; got.Rect.X != cx+96 || got.Rect.Y != cy+44 || got.ID != dndIcon {
363+ t.Fatalf("moved icon %+v", got)
364+ }
365+ // Under the icon, the surface below is still the target.
366+ d.drain()
367+ d.moveTo(float64(cx+101), float64(cy+50), 3)
368+ d.settle(t)
369+ for _, v := range d.drain() {
370+ if f, ok := v.(ports.DataDragFocus); ok && f.ID == dndIcon {
371+ t.Fatalf("icon was targeted: %#v", f)
372+ }
373+ }
374+ // A commit that changes the offset moves it; a zero size hides it.
375+ d.client <- ports.DataDragIcon{ID: dndIcon, X: 0, Y: 0, W: 32, H: 24}
376+ if got := dragScene(d.settle(t), "A"); got.Windows[len(got.Windows)-1].Rect.X != cx+101 {
377+ t.Fatalf("offset ignored: %+v", got.Windows[len(got.Windows)-1])
378+ }
379+ d.client <- ports.DataDragIcon{ID: dndIcon}
380+ if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
381+ t.Fatal("hidden icon drawn")
382+ }
383+ // Geometry of another surface is not the icon's.
384+ d.client <- ports.DataDragIcon{ID: dndIcon + 1, W: 10, H: 10}
385+ if _, ok := windowIn(d.settle(t), "A", dndIcon+1); ok {
386+ t.Fatal("foreign icon drawn")
387+ }
388+ // The drop removes it.
389+ d.client <- ports.DataDragIcon{ID: dndIcon, W: 32, H: 24}
390+ d.settle(t)
391+ d.in(ports.PointerButton{Button: 0x110})
392+ set = d.settle(t)
393+ if _, ok := windowIn(set, "A", dndIcon); ok {
394+ t.Fatal("icon drawn after the drop")
395+ }
396+}
397+
398+func TestDataDragIconOnScreenUnderCursor(t *testing.T) {
399+ other := ports.OutputInfo{Name: "B", Width: 800, Height: 600}
400+ d := startDnd(t, dragOut, other)
401+ d.start(t, dndIcon)
402+ d.client <- ports.DataDragIcon{ID: dndIcon, X: 2, Y: 3, W: 10, H: 10}
403+ d.moveTo(1000+100, 50, 2)
404+ set := d.settle(t)
405+ if _, ok := windowIn(set, "A", dndIcon); ok {
406+ t.Fatal("icon on the other screen")
407+ }
408+ w, ok := windowIn(set, "B", dndIcon)
409+ if !ok {
410+ t.Fatal("no icon on B")
411+ }
412+ if w.Rect != (ports.Rect{X: 102, Y: 53, W: 10, H: 10}) {
413+ t.Fatalf("icon %+v", w.Rect)
414+ }
415+}
416+
417+func TestDataDragNoIconIgnoresIconEvents(t *testing.T) {
418+ d := startDnd(t)
419+ d.start(t, 0)
420+ d.client <- ports.DataDragIcon{ID: dndIcon, W: 10, H: 10}
421+ if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
422+ t.Fatal("icon drawn for a drag without one")
423+ }
424+ // Nor with no drag at all.
425+ d.in(ports.PointerButton{Button: 0x110})
426+ d.settle(t)
427+ d.client <- ports.DataDragIcon{ID: dndIcon, W: 10, H: 10}
428+ if _, ok := windowIn(d.settle(t), "A", dndIcon); ok {
429+ t.Fatal("icon drawn without a drag")
430+ }
431+}
432+
433+func TestDataDragFocusScreenFollowsPointer(t *testing.T) {
434+ r := startDragRig(t, nil, dragOut, ports.OutputInfo{Name: "B", Width: 1000, Height: 600})
435+ r.client <- ports.WindowMapped{ID: 1}
436+ r.settle(t)
437+ r.moveTo(1500, 300, 1)
438+ r.settle(t)
439+ r.client <- ports.WindowMapped{ID: 3}
440+ set := r.settle(t)
441+ one := dragRect(t, set, "A", 1)
442+ r.moveTo(float64(one.X+20), float64(one.Y+30), 2)
443+ set = r.settle(t)
444+ if w, _ := windowIn(set, "A", 1); !w.Focused {
445+ t.Fatal("1 not focused")
446+ }
447+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
448+ r.settle(t)
449+ r.client <- ports.DataDragStarted{}
450+ r.moveTo(1500, 300, 3)
451+ set = r.settle(t)
452+ if w, _ := windowIn(set, "B", 3); !w.Focused {
453+ t.Fatal("the screen under the drag did not take the focus")
454+ }
455+ three := dragRect(t, set, "B", 3)
456+ var focus []ports.DataDragFocus
457+ for _, v := range r.drain() {
458+ if f, ok := v.(ports.DataDragFocus); ok {
459+ focus = append(focus, f)
460+ }
461+ }
462+ if n := len(focus); n != 2 || focus[1].ID != 3 || focus[1].X != float64(1500-1000-three.X) {
463+ t.Fatalf("focus %+v", focus)
464+ }
465+}
M internal/core/input.go +14 -1
@@ -98,6 +98,11 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
9898 // The dragged window and the others get no pointer events.
9999 return c.dragMotion(ctx, v.Time)
100100 }
101+ if c.dataDrag != nil {
102+ // A data drag goes to the surface under the pointer, by its own
103+ // commands.
104+ return c.dataDragMotion(ctx, v.Time)
105+ }
101106 id, x, y := c.hit(c.cursorX, c.cursorY)
102107 // Layout changes are intentionally re-hit-tested only on motion.
103108 if id != c.pointer {
@@ -123,6 +128,9 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
123128 delete(c.buttons, v.Button)
124129 return nil
125130 }
131+ if c.dataDrag != nil {
132+ return c.dataDragButton(ctx, v)
133+ }
126134 if d := c.drag; d != nil {
127135 if !v.Pressed && v.Button == d.button {
128136 delete(c.buttons, v.Button)
@@ -226,7 +234,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
226234 }
227235 // Scroll goes to the window under the pointer, which has the
228236 // pointer focus even mid-drag. None during a window drag.
229- if c.pointer != 0 && c.drag == nil {
237+ if c.pointer != 0 && c.drag == nil && c.dataDrag == nil {
230238 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
231239 return err
232240 }
@@ -249,6 +257,11 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
249257 c.pressed[heldKey(key)] = true
250258 return c.abortSwitcher(ctx)
251259 }
260+ if c.dataDrag != nil && key.Pressed && key.Keysym == "Escape" {
261+ // Escape cancels the data drag; its release goes nowhere either.
262+ c.pressed[heldKey(key)] = true
263+ return c.cancelDataDrag(ctx)
264+ }
252265 if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
253266 // Escape cancels the drag; its release goes nowhere either.
254267 c.cancelDrag()
@@ -98,6 +98,11 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
98 // The dragged window and the others get no pointer events.98 // The dragged window and the others get no pointer events.
99 return c.dragMotion(ctx, v.Time)99 return c.dragMotion(ctx, v.Time)
100 }100 }
101+ if c.dataDrag != nil {
102+ // A data drag goes to the surface under the pointer, by its own
103+ // commands.
104+ return c.dataDragMotion(ctx, v.Time)
105+ }
101 id, x, y := c.hit(c.cursorX, c.cursorY)106 id, x, y := c.hit(c.cursorX, c.cursorY)
102 // Layout changes are intentionally re-hit-tested only on motion.107 // Layout changes are intentionally re-hit-tested only on motion.
103 if id != c.pointer {108 if id != c.pointer {
@@ -123,6 +128,9 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
123 delete(c.buttons, v.Button)128 delete(c.buttons, v.Button)
124 return nil129 return nil
125 }130 }
131+ if c.dataDrag != nil {
132+ return c.dataDragButton(ctx, v)
133+ }
126 if d := c.drag; d != nil {134 if d := c.drag; d != nil {
127 if !v.Pressed && v.Button == d.button {135 if !v.Pressed && v.Button == d.button {
128 delete(c.buttons, v.Button)136 delete(c.buttons, v.Button)
@@ -226,7 +234,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
226 }234 }
227 // Scroll goes to the window under the pointer, which has the235 // Scroll goes to the window under the pointer, which has the
228 // pointer focus even mid-drag. None during a window drag.236 // pointer focus even mid-drag. None during a window drag.
229- if c.pointer != 0 && c.drag == nil {237+ if c.pointer != 0 && c.drag == nil && c.dataDrag == nil {
230 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {238 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
231 return err239 return err
232 }240 }
@@ -249,6 +257,11 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
249 c.pressed[heldKey(key)] = true257 c.pressed[heldKey(key)] = true
250 return c.abortSwitcher(ctx)258 return c.abortSwitcher(ctx)
251 }259 }
260+ if c.dataDrag != nil && key.Pressed && key.Keysym == "Escape" {
261+ // Escape cancels the data drag; its release goes nowhere either.
262+ c.pressed[heldKey(key)] = true
263+ return c.cancelDataDrag(ctx)
264+ }
252 if c.drag != nil && key.Pressed && key.Keysym == "Escape" {265 if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
253 // Escape cancels the drag; its release goes nowhere either.266 // Escape cancels the drag; its release goes nowhere either.
254 c.cancelDrag()267 c.cancelDrag()
M internal/core/scene.go +4 -0
@@ -126,6 +126,10 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
126126 }
127127 }
128128 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
129+ if icon, ok := c.dataDragIconOn(i, sc); ok {
130+ // The drag icon is over everything, popups and layers included.
131+ scene.Windows = append(scene.Windows, icon)
132+ }
129133 scene.CaptureIndicators = c.captureIndicators(sc)
130134 scene.Capture = c.captureSceneFor(sc, capture)
131135 // The springs of this frame are advanced: a scene shown while one still
@@ -126,6 +126,10 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
126 }126 }
127 }127 }
128 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)128 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
129+ if icon, ok := c.dataDragIconOn(i, sc); ok {
130+ // The drag icon is over everything, popups and layers included.
131+ scene.Windows = append(scene.Windows, icon)
132+ }
129 scene.CaptureIndicators = c.captureIndicators(sc)133 scene.CaptureIndicators = c.captureIndicators(sc)
130 scene.Capture = c.captureSceneFor(sc, capture)134 scene.Capture = c.captureSceneFor(sc, capture)
131 // The springs of this frame are advanced: a scene shown while one still135 // The springs of this frame are advanced: a scene shown while one still
M internal/core/security.go +2 -0
@@ -33,6 +33,8 @@ func (c *Core) syncSecurity() bool {
3333 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}
3434 c.pointer, c.grab, c.pointerOutput = 0, 0, ""
3535 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
36+ // Wayland cancels its own drag when the epoch changes.
37+ c.dataDrag = nil
3638 c.pointerAt, c.motionTime = [2]float64{}, 0
3739 c.keyboard = keyboard{}
3840 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}
@@ -33,6 +33,8 @@ func (c *Core) syncSecurity() bool {
33 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}33 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}
34 c.pointer, c.grab, c.pointerOutput = 0, 0, ""34 c.pointer, c.grab, c.pointerOutput = 0, 0, ""
35 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 035 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
36+ // Wayland cancels its own drag when the epoch changes.
37+ c.dataDrag = nil
36 c.pointerAt, c.motionTime = [2]float64{}, 038 c.pointerAt, c.motionTime = [2]float64{}, 0
37 c.keyboard = keyboard{}39 c.keyboard = keyboard{}
38 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}40 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}

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 23

Tags 10

↑↓ moveenter openesc close