detached e05cb15

fix(dnd): tie data drags to the implicit grab button and follow target moves

commit
e05cb150e25a0922622fbeca7a6ea6a57c732a37
author
brice · · 2026-10-17 15:15 UTC
parent
3a8fe0c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
feat/data-dnd
10 files · +115 -43
M internal/adapters/wayland/dnd.go +6 -7
@@ -174,12 +174,11 @@ func (s *Server) startDrag(r *wayland.DataDevice, srcRes *wayland.DataSource, or
174174 iconSurf.role = func(bool) { s.dndIconRole(d, iconSurf) }
175175 }
176176 s.dnd = d
177- // Core consumes the releases of a drag: the buttons are not held for
178- // the next press.
179- clear(s.seat.pointerHeld)
180- s.seat.grabSerial = 0
177+ // Core consumes the releases of a drag: the grab ends here.
178+ button := s.seat.grabButton
179+ s.seat.grabSerial, s.seat.grabButton = 0, 0
181180 s.log.Info().Uint64("icon", uint64(d.iconID)).Bool("source", cs != nil).Msg("data drag started")
182- s.emit(ports.DataDragStarted{Icon: d.iconID})
181+ s.emit(ports.DataDragStarted{Button: button, Icon: d.iconID})
183182 if iconSurf != nil && iconSurf.has {
184183 // The icon was committed before the drag: show what it holds.
185184 iconSurf.committed = damage{full: true}
@@ -188,10 +187,10 @@ func (s *Server) startDrag(r *wayland.DataDevice, srcRes *wayland.DataSource, or
188187 }
189188 }
190189
191-// validGrab reports whether serial is the press of the pointer button still
190+// validGrab reports whether serial is the press of the implicit grab still
192191 // held, on the surface with pointer focus (or a subsurface of its tree).
193192 func (s *Server) validGrab(origin *wayland.Surface, serial uint32) bool {
194- if serial == 0 || serial != s.seat.grabSerial || len(s.seat.pointerHeld) == 0 {
193+ if serial == 0 || serial != s.seat.grabSerial {
195194 return false
196195 }
197196 focus, _, _, _ := s.pointerSurface(s.seat.pointerFocus, 0, 0)
@@ -174,12 +174,11 @@ func (s *Server) startDrag(r *wayland.DataDevice, srcRes *wayland.DataSource, or
174 iconSurf.role = func(bool) { s.dndIconRole(d, iconSurf) }174 iconSurf.role = func(bool) { s.dndIconRole(d, iconSurf) }
175 }175 }
176 s.dnd = d176 s.dnd = d
177- // Core consumes the releases of a drag: the buttons are not held for177+ // Core consumes the releases of a drag: the grab ends here.
178- // the next press.178+ button := s.seat.grabButton
179- clear(s.seat.pointerHeld)179+ s.seat.grabSerial, s.seat.grabButton = 0, 0
180- s.seat.grabSerial = 0
181 s.log.Info().Uint64("icon", uint64(d.iconID)).Bool("source", cs != nil).Msg("data drag started")180 s.log.Info().Uint64("icon", uint64(d.iconID)).Bool("source", cs != nil).Msg("data drag started")
182- s.emit(ports.DataDragStarted{Icon: d.iconID})181+ s.emit(ports.DataDragStarted{Button: button, Icon: d.iconID})
183 if iconSurf != nil && iconSurf.has {182 if iconSurf != nil && iconSurf.has {
184 // The icon was committed before the drag: show what it holds.183 // The icon was committed before the drag: show what it holds.
185 iconSurf.committed = damage{full: true}184 iconSurf.committed = damage{full: true}
@@ -188,10 +187,10 @@ func (s *Server) startDrag(r *wayland.DataDevice, srcRes *wayland.DataSource, or
188 }187 }
189 }188 }
190 189
191-// validGrab reports whether serial is the press of the pointer button still190+// validGrab reports whether serial is the press of the implicit grab still
192 // held, on the surface with pointer focus (or a subsurface of its tree).191 // held, on the surface with pointer focus (or a subsurface of its tree).
193 func (s *Server) validGrab(origin *wayland.Surface, serial uint32) bool {192 func (s *Server) validGrab(origin *wayland.Surface, serial uint32) bool {
194- if serial == 0 || serial != s.seat.grabSerial || len(s.seat.pointerHeld) == 0 {193+ if serial == 0 || serial != s.seat.grabSerial {
195 return false194 return false
196 }195 }
197 focus, _, _, _ := s.pointerSurface(s.seat.pointerFocus, 0, 0)196 focus, _, _, _ := s.pointerSurface(s.seat.pointerFocus, 0, 0)
M internal/adapters/wayland/dnd_test.go +24 -1
@@ -232,7 +232,7 @@ func (d *dndClient) source(t *testing.T, actions uint32, mimes ...string) uint32
232232 func (d *dndClient) press(t *testing.T, commands chan<- ports.ClientCommand) uint32 {
233233 t.Helper()
234234 commands <- ports.PointerFocus{ID: d.window, X: 1, Y: 1}
235- commands <- ports.PointerButtonTo{ID: d.window, Button: 0x110, Pressed: true, Time: time.Millisecond}
235+ commands <- ports.PointerButtonTo{ID: d.window, Button: 0x110, Pressed: true, Grab: true, Time: time.Millisecond}
236236 deadline := time.After(2 * time.Second)
237237 for {
238238 if err := d.c.Roundtrip(); err != nil {
@@ -652,8 +652,31 @@ func TestDataDragInvalidStart(t *testing.T) {
652652 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, second)
653653 p.a.expect(t, "cancelled")
654654 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, first)
655+ // Core is told the grab's button: its release drops.
656+ if ev := dndNextEvent[ports.DataDragStarted](t, p.events); ev.Button != 0x110 {
657+ t.Fatalf("drag button %#x, want the grab's", ev.Button)
658+ }
659+ })
660+ t.Run("grab release consumed by core", func(t *testing.T) {
661+ // A window drag consumes the release: the next grab press
662+ // still makes the serial a drag can name.
663+ p := newDndPair(t)
664+ stale := p.a.press(t, p.commands)
665+ serial := p.a.press(t, p.commands)
666+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, stale)
667+ p.a.expect(t, "cancelled")
668+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
655669 dndNextEvent[ports.DataDragStarted](t, p.events)
656670 })
671+ t.Run("grab release of a gone window", func(t *testing.T) {
672+ p := newDndPair(t)
673+ serial := p.a.press(t, p.commands)
674+ p.commands <- ports.PointerButtonTo{ID: 9999, Button: 0x110, Time: 2 * time.Millisecond}
675+ p.a.key(t, p.commands) // the release is applied once the key is seen
676+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
677+ p.a.expect(t, "cancelled")
678+ dndNoEvent[ports.DataDragStarted](t, p.events)
679+ })
657680 t.Run("used source", func(t *testing.T) {
658681 p := newDndPair(t)
659682 src := p.start(t, 0)
@@ -232,7 +232,7 @@ func (d *dndClient) source(t *testing.T, actions uint32, mimes ...string) uint32
232 func (d *dndClient) press(t *testing.T, commands chan<- ports.ClientCommand) uint32 {232 func (d *dndClient) press(t *testing.T, commands chan<- ports.ClientCommand) uint32 {
233 t.Helper()233 t.Helper()
234 commands <- ports.PointerFocus{ID: d.window, X: 1, Y: 1}234 commands <- ports.PointerFocus{ID: d.window, X: 1, Y: 1}
235- commands <- ports.PointerButtonTo{ID: d.window, Button: 0x110, Pressed: true, Time: time.Millisecond}235+ commands <- ports.PointerButtonTo{ID: d.window, Button: 0x110, Pressed: true, Grab: true, Time: time.Millisecond}
236 deadline := time.After(2 * time.Second)236 deadline := time.After(2 * time.Second)
237 for {237 for {
238 if err := d.c.Roundtrip(); err != nil {238 if err := d.c.Roundtrip(); err != nil {
@@ -652,8 +652,31 @@ func TestDataDragInvalidStart(t *testing.T) {
652 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, second)652 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, second)
653 p.a.expect(t, "cancelled")653 p.a.expect(t, "cancelled")
654 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, first)654 p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, first)
655+ // Core is told the grab's button: its release drops.
656+ if ev := dndNextEvent[ports.DataDragStarted](t, p.events); ev.Button != 0x110 {
657+ t.Fatalf("drag button %#x, want the grab's", ev.Button)
658+ }
659+ })
660+ t.Run("grab release consumed by core", func(t *testing.T) {
661+ // A window drag consumes the release: the next grab press
662+ // still makes the serial a drag can name.
663+ p := newDndPair(t)
664+ stale := p.a.press(t, p.commands)
665+ serial := p.a.press(t, p.commands)
666+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, stale)
667+ p.a.expect(t, "cancelled")
668+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
655 dndNextEvent[ports.DataDragStarted](t, p.events)669 dndNextEvent[ports.DataDragStarted](t, p.events)
656 })670 })
671+ t.Run("grab release of a gone window", func(t *testing.T) {
672+ p := newDndPair(t)
673+ serial := p.a.press(t, p.commands)
674+ p.commands <- ports.PointerButtonTo{ID: 9999, Button: 0x110, Time: 2 * time.Millisecond}
675+ p.a.key(t, p.commands) // the release is applied once the key is seen
676+ p.a.startDrag(t, p.a.source(t, actCopy, "text/plain"), 0, serial)
677+ p.a.expect(t, "cancelled")
678+ dndNoEvent[ports.DataDragStarted](t, p.events)
679+ })
657 t.Run("used source", func(t *testing.T) {680 t.Run("used source", func(t *testing.T) {
658 p := newDndPair(t)681 p := newDndPair(t)
659 src := p.start(t, 0)682 src := p.start(t, 0)
M internal/adapters/wayland/seat.go +14 -16
@@ -43,11 +43,13 @@ type seatState struct {
4343 // pressClient: popup grabs must come from it.
4444 press uint32
4545 pressClient server.Client
46- // pointerHeld are the pointer buttons held, and grabSerial the serial of
47- // the press that took the first one: the only serial start_drag accepts
48- // (wlr_seat_validate_pointer_grab_serial).
49- pointerHeld map[uint32]bool
50- grabSerial uint32
46+ // grabSerial is the serial of the press that started the implicit
47+ // grab (core marks it), grabButton its button: the only serial
48+ // start_drag accepts (wlr_seat_validate_pointer_grab_serial). The
49+ // grab's release clears it; core may consume that release (a window
50+ // drag), and then the next grab press replaces it.
51+ grabSerial uint32
52+ grabButton uint32
5153 // pressAt and focusAt date the last press and keyboard focus change,
5254 // for xdg-activation tokens.
5355 pressAt, focusAt time.Time
@@ -132,23 +134,19 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
132134 }
133135 case ports.PointerButtonTo:
134136 // Releases may target the implicit-grab window after focus has moved.
137+ if c.Pressed && c.Grab || !c.Pressed && c.Button == s.seat.grabButton {
138+ // A new grab, or the end of this one even when its window is
139+ // gone.
140+ s.seat.grabSerial, s.seat.grabButton = 0, 0
141+ }
135142 if client, pointers, ok := s.pointerTarget(c.ID); ok {
136143 s.serial++
137144 state := uint32(0)
138145 if c.Pressed {
139146 state = 1
140147 s.seat.press, s.seat.pressClient, s.seat.pressAt = s.serial, client, time.Now()
141- if len(s.seat.pointerHeld) == 0 {
142- s.seat.grabSerial = s.serial
143- }
144- if s.seat.pointerHeld == nil {
145- s.seat.pointerHeld = make(map[uint32]bool)
146- }
147- s.seat.pointerHeld[c.Button] = true
148- } else {
149- delete(s.seat.pointerHeld, c.Button)
150- if len(s.seat.pointerHeld) == 0 {
151- s.seat.grabSerial = 0
148+ if c.Grab {
149+ s.seat.grabSerial, s.seat.grabButton = s.serial, c.Button
152150 }
153151 }
154152 for _, p := range pointers {
@@ -43,11 +43,13 @@ type seatState struct {
43 // pressClient: popup grabs must come from it.43 // pressClient: popup grabs must come from it.
44 press uint3244 press uint32
45 pressClient server.Client45 pressClient server.Client
46- // pointerHeld are the pointer buttons held, and grabSerial the serial of46+ // grabSerial is the serial of the press that started the implicit
47- // the press that took the first one: the only serial start_drag accepts47+ // grab (core marks it), grabButton its button: the only serial
48- // (wlr_seat_validate_pointer_grab_serial).48+ // start_drag accepts (wlr_seat_validate_pointer_grab_serial). The
49- pointerHeld map[uint32]bool49+ // grab's release clears it; core may consume that release (a window
50- grabSerial uint3250+ // drag), and then the next grab press replaces it.
51+ grabSerial uint32
52+ grabButton uint32
51 // pressAt and focusAt date the last press and keyboard focus change,53 // pressAt and focusAt date the last press and keyboard focus change,
52 // for xdg-activation tokens.54 // for xdg-activation tokens.
53 pressAt, focusAt time.Time55 pressAt, focusAt time.Time
@@ -132,23 +134,19 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
132 }134 }
133 case ports.PointerButtonTo:135 case ports.PointerButtonTo:
134 // Releases may target the implicit-grab window after focus has moved.136 // Releases may target the implicit-grab window after focus has moved.
137+ if c.Pressed && c.Grab || !c.Pressed && c.Button == s.seat.grabButton {
138+ // A new grab, or the end of this one even when its window is
139+ // gone.
140+ s.seat.grabSerial, s.seat.grabButton = 0, 0
141+ }
135 if client, pointers, ok := s.pointerTarget(c.ID); ok {142 if client, pointers, ok := s.pointerTarget(c.ID); ok {
136 s.serial++143 s.serial++
137 state := uint32(0)144 state := uint32(0)
138 if c.Pressed {145 if c.Pressed {
139 state = 1146 state = 1
140 s.seat.press, s.seat.pressClient, s.seat.pressAt = s.serial, client, time.Now()147 s.seat.press, s.seat.pressClient, s.seat.pressAt = s.serial, client, time.Now()
141- if len(s.seat.pointerHeld) == 0 {148+ if c.Grab {
142- s.seat.grabSerial = s.serial149+ s.seat.grabSerial, s.seat.grabButton = s.serial, c.Button
143- }
144- if s.seat.pointerHeld == nil {
145- s.seat.pointerHeld = make(map[uint32]bool)
146- }
147- s.seat.pointerHeld[c.Button] = true
148- } else {
149- delete(s.seat.pointerHeld, c.Button)
150- if len(s.seat.pointerHeld) == 0 {
151- s.seat.grabSerial = 0
152 }150 }
153 }151 }
154 for _, p := range pointers {152 for _, p := range pointers {
M internal/adapters/wayland/session_lock.go +1 -2
@@ -147,8 +147,7 @@ func (s *Server) resetProtectionInput() {
147147 clear(s.seat.grabKeys)
148148 s.seat.modState = ports.ModState{}
149149 s.seat.press, s.seat.pressClient = 0, server.Client{}
150- clear(s.seat.pointerHeld)
151- s.seat.grabSerial = 0
150+ s.seat.grabSerial, s.seat.grabButton = 0, 0
152151 s.seat.wheelRest, s.seat.wheelHeld = [2]int32{}, [2]float64{}
153152 s.cursorSurface = nil
154153 }
@@ -147,8 +147,7 @@ func (s *Server) resetProtectionInput() {
147 clear(s.seat.grabKeys)147 clear(s.seat.grabKeys)
148 s.seat.modState = ports.ModState{}148 s.seat.modState = ports.ModState{}
149 s.seat.press, s.seat.pressClient = 0, server.Client{}149 s.seat.press, s.seat.pressClient = 0, server.Client{}
150- clear(s.seat.pointerHeld)150+ s.seat.grabSerial, s.seat.grabButton = 0, 0
151- s.seat.grabSerial = 0
152 s.seat.wheelRest, s.seat.wheelHeld = [2]int32{}, [2]float64{}151 s.seat.wheelRest, s.seat.wheelHeld = [2]int32{}, [2]float64{}
153 s.cursorSurface = nil152 s.cursorSurface = nil
154 }153 }
M internal/core/core_test.go +1 -1
@@ -307,7 +307,7 @@ func TestPointerFocusAndGrab(t *testing.T) {
307307 t.Fatal(v)
308308 }
309309 input <- ports.PointerButton{Button: 0x110, Pressed: true}
310- if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {
310+ if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true, Grab: true}) {
311311 t.Fatal(v)
312312 }
313313 scene(t, scenes)
@@ -307,7 +307,7 @@ func TestPointerFocusAndGrab(t *testing.T) {
307 t.Fatal(v)307 t.Fatal(v)
308 }308 }
309 input <- ports.PointerButton{Button: 0x110, Pressed: true}309 input <- ports.PointerButton{Button: 0x110, Pressed: true}
310- if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {310+ if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true, Grab: true}) {
311 t.Fatal(v)311 t.Fatal(v)
312 }312 }
313 scene(t, scenes)313 scene(t, scenes)
M internal/core/datadrag.go +9 -5
@@ -31,8 +31,10 @@ import (
3131 type dataDragState struct {
3232 // button is the pointer button that holds the drag.
3333 button uint32
34- // target is the surface the drag was last routed to (0: none).
34+ // target is the surface the drag was last routed to (0: none), at
35+ // at in its coordinates.
3536 target WindowID
37+ at [2]float64
3638 icon dataDragIcon
3739 }
3840
@@ -47,7 +49,7 @@ type dataDragIcon struct {
4749 // pointer if a client holds a button, else cancels the drag. A drag
4850 // during a window drag or a locked session is refused.
4951 func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {
50- button := c.lastButton
52+ button := v.Button
5153 if c.dataDrag != nil || c.drag != nil || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
5254 return c.command(ctx, ports.DataDragCancel{})
5355 }
@@ -81,6 +83,7 @@ func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error
8183 func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
8284 d := c.dataDrag
8385 id, x, y := c.hit(c.cursorX, c.cursorY)
86+ d.at = [2]float64{x, y}
8487 switch {
8588 case id != d.target:
8689 d.target = id
@@ -91,10 +94,11 @@ func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
9194 return nil
9295 }
9396
94-// dataDragRetarget re-routes a still cursor: only a changed target is
95-// told.
97+// dataDragRetarget re-routes a still cursor: the target is told only when
98+// it or the point in it changed.
9699 func (c *Core) dataDragRetarget(ctx context.Context, at time.Duration) error {
97- if id, _, _ := c.hit(c.cursorX, c.cursorY); id == c.dataDrag.target {
100+ d := c.dataDrag
101+ if id, x, y := c.hit(c.cursorX, c.cursorY); id == d.target && (id == 0 || d.at == [2]float64{x, y}) {
98102 return nil
99103 }
100104 return c.dataDragRoute(ctx, at)
@@ -31,8 +31,10 @@ import (
31 type dataDragState struct {31 type dataDragState struct {
32 // button is the pointer button that holds the drag.32 // button is the pointer button that holds the drag.
33 button uint3233 button uint32
34- // target is the surface the drag was last routed to (0: none).34+ // target is the surface the drag was last routed to (0: none), at
35+ // at in its coordinates.
35 target WindowID36 target WindowID
37+ at [2]float64
36 icon dataDragIcon38 icon dataDragIcon
37 }39 }
38 40
@@ -47,7 +49,7 @@ type dataDragIcon struct {
47 // pointer if a client holds a button, else cancels the drag. A drag49 // pointer if a client holds a button, else cancels the drag. A drag
48 // during a window drag or a locked session is refused.50 // during a window drag or a locked session is refused.
49 func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {51 func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {
50- button := c.lastButton52+ button := v.Button
51 if c.dataDrag != nil || c.drag != nil || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {53 if c.dataDrag != nil || c.drag != nil || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
52 return c.command(ctx, ports.DataDragCancel{})54 return c.command(ctx, ports.DataDragCancel{})
53 }55 }
@@ -81,6 +83,7 @@ func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error
81 func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {83 func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
82 d := c.dataDrag84 d := c.dataDrag
83 id, x, y := c.hit(c.cursorX, c.cursorY)85 id, x, y := c.hit(c.cursorX, c.cursorY)
86+ d.at = [2]float64{x, y}
84 switch {87 switch {
85 case id != d.target:88 case id != d.target:
86 d.target = id89 d.target = id
@@ -91,10 +94,11 @@ func (c *Core) dataDragRoute(ctx context.Context, at time.Duration) error {
91 return nil94 return nil
92 }95 }
93 96
94-// dataDragRetarget re-routes a still cursor: only a changed target is97+// dataDragRetarget re-routes a still cursor: the target is told only when
95-// told.98+// it or the point in it changed.
96 func (c *Core) dataDragRetarget(ctx context.Context, at time.Duration) error {99 func (c *Core) dataDragRetarget(ctx context.Context, at time.Duration) error {
97- if id, _, _ := c.hit(c.cursorX, c.cursorY); id == c.dataDrag.target {100+ d := c.dataDrag
101+ if id, x, y := c.hit(c.cursorX, c.cursorY); id == d.target && (id == 0 || d.at == [2]float64{x, y}) {
98 return nil102 return nil
99 }103 }
100 return c.dataDragRoute(ctx, at)104 return c.dataDragRoute(ctx, at)
M internal/core/datadrag_test.go +49 -6
@@ -51,7 +51,7 @@ func dataDragCommands(cmds []ports.ClientCommand) []ports.ClientCommand {
5151
5252 func (d *dndRig) start(t *testing.T, icon ports.WindowID) []ports.ClientCommand {
5353 t.Helper()
54- d.client <- ports.DataDragStarted{Icon: icon}
54+ d.client <- ports.DataDragStarted{Button: 0x110, Icon: icon}
5555 d.settle(t)
5656 return dataDragCommands(d.drain())
5757 }
@@ -108,7 +108,7 @@ func TestDataDragDropsOnRelease(t *testing.T) {
108108 // The next press is an ordinary click.
109109 d.in(ports.PointerButton{Button: 0x110, Pressed: true})
110110 d.settle(t)
111- wantCommands(t, dataDragCommands(d.drain()), ports.PointerButtonTo{ID: 2, Button: 0x110, Pressed: true})
111+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerButtonTo{ID: 2, Button: 0x110, Pressed: true, Grab: true})
112112 }
113113
114114 func TestDataDragSendsNothingToClients(t *testing.T) {
@@ -231,7 +231,7 @@ func TestDataDragRefused(t *testing.T) {
231231 r.press(t, 0x110, ports.ModSuper)
232232 r.settle(t)
233233 r.drain()
234- r.client <- ports.DataDragStarted{}
234+ r.client <- ports.DataDragStarted{Button: 0x110}
235235 r.settle(t)
236236 wantCommands(t, r.drain(), ports.DataDragCancel{})
237237 // The window drag goes on.
@@ -252,7 +252,7 @@ func TestDataDragRefused(t *testing.T) {
252252 d.client <- ports.SessionLockChanged{State: locked}
253253 d.settle(t)
254254 d.drain()
255- d.client <- ports.DataDragStarted{}
255+ d.client <- ports.DataDragStarted{Button: 0x110}
256256 d.settle(t)
257257 var got []ports.ClientCommand
258258 for _, v := range d.drain() {
@@ -267,7 +267,7 @@ func TestDataDragRefused(t *testing.T) {
267267
268268 func TestDataDragCommandsAreSecurityWrapped(t *testing.T) {
269269 d := startDnd(t)
270- d.client <- ports.DataDragStarted{}
270+ d.client <- ports.DataDragStarted{Button: 0x110}
271271 d.settle(t)
272272 n := 0
273273 for {
@@ -446,7 +446,7 @@ func TestDataDragFocusScreenFollowsPointer(t *testing.T) {
446446 }
447447 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
448448 r.settle(t)
449- r.client <- ports.DataDragStarted{}
449+ r.client <- ports.DataDragStarted{Button: 0x110}
450450 r.moveTo(1500, 300, 3)
451451 set = r.settle(t)
452452 if w, _ := windowIn(set, "B", 3); !w.Focused {
@@ -492,6 +492,49 @@ func TestDataDragFollowsWorkspaceSwitchUnderStillCursor(t *testing.T) {
492492 }
493493 }
494494
495+func TestDataDragTargetMovedUnderStillCursor(t *testing.T) {
496+ d := startDnd(t)
497+ d.start(t, 0)
498+ // Over 2, focused by a click-free keyboard move, then cmd+f maximizes
499+ // its column: still the target under the cursor, at another point.
500+ d.moveTo(float64(d.two.X+d.two.W-20), float64(d.two.Y+30), 2)
501+ d.in(ports.KeyEvent{Keysym: "l", Keycode: 38, Mods: ports.ModSuper, Pressed: true})
502+ d.in(ports.KeyEvent{Keysym: "l", Keycode: 38, Mods: ports.ModSuper})
503+ d.settle(t)
504+ d.drain()
505+ d.in(ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
506+ d.in(ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper})
507+ set := d.settle(t)
508+ wide := dragRect(t, set, dragOut.Name, 2)
509+ if wide.X == d.two.X {
510+ t.Fatalf("the column did not move: %v", wide)
511+ }
512+ x, y := float64(d.two.X+d.two.W-20-wide.X), float64(d.two.Y+30-wide.Y)
513+ got := dataDragCommands(d.drain())
514+ if !slices.Contains(got, ports.ClientCommand(ports.DataDragMotion{ID: 2, X: x, Y: y, Time: 2 * time.Millisecond})) {
515+ t.Fatalf("no motion to %v,%v: %#v", x, y, got)
516+ }
517+}
518+
519+func TestDataDragDropsOnItsOwnButton(t *testing.T) {
520+ d := startDnd(t)
521+ // Right pressed after left: the drag is left's, the grab's.
522+ d.in(ports.PointerButton{Button: 0x111, Pressed: true})
523+ d.settle(t)
524+ d.drain()
525+ d.start(t, 0)
526+ d.in(ports.PointerButton{Button: 0x111})
527+ d.settle(t)
528+ if got := dataDragCommands(d.drain()); slices.Contains(got, ports.ClientCommand(ports.DataDragDrop{})) {
529+ t.Fatalf("right's release dropped: %#v", got)
530+ }
531+ d.in(ports.PointerButton{Button: 0x110})
532+ d.settle(t)
533+ if got := dataDragCommands(d.drain()); len(got) == 0 || got[0] != (ports.DataDragDrop{}) {
534+ t.Fatalf("left's release did not drop: %#v", got)
535+ }
536+}
537+
495538 func TestDataDragEscapeCancelsOverSwitcher(t *testing.T) {
496539 d := startDnd(t)
497540 d.start(t, 0)
@@ -51,7 +51,7 @@ func dataDragCommands(cmds []ports.ClientCommand) []ports.ClientCommand {
51 51
52 func (d *dndRig) start(t *testing.T, icon ports.WindowID) []ports.ClientCommand {52 func (d *dndRig) start(t *testing.T, icon ports.WindowID) []ports.ClientCommand {
53 t.Helper()53 t.Helper()
54- d.client <- ports.DataDragStarted{Icon: icon}54+ d.client <- ports.DataDragStarted{Button: 0x110, Icon: icon}
55 d.settle(t)55 d.settle(t)
56 return dataDragCommands(d.drain())56 return dataDragCommands(d.drain())
57 }57 }
@@ -108,7 +108,7 @@ func TestDataDragDropsOnRelease(t *testing.T) {
108 // The next press is an ordinary click.108 // The next press is an ordinary click.
109 d.in(ports.PointerButton{Button: 0x110, Pressed: true})109 d.in(ports.PointerButton{Button: 0x110, Pressed: true})
110 d.settle(t)110 d.settle(t)
111- wantCommands(t, dataDragCommands(d.drain()), ports.PointerButtonTo{ID: 2, Button: 0x110, Pressed: true})111+ wantCommands(t, dataDragCommands(d.drain()), ports.PointerButtonTo{ID: 2, Button: 0x110, Pressed: true, Grab: true})
112 }112 }
113 113
114 func TestDataDragSendsNothingToClients(t *testing.T) {114 func TestDataDragSendsNothingToClients(t *testing.T) {
@@ -231,7 +231,7 @@ func TestDataDragRefused(t *testing.T) {
231 r.press(t, 0x110, ports.ModSuper)231 r.press(t, 0x110, ports.ModSuper)
232 r.settle(t)232 r.settle(t)
233 r.drain()233 r.drain()
234- r.client <- ports.DataDragStarted{}234+ r.client <- ports.DataDragStarted{Button: 0x110}
235 r.settle(t)235 r.settle(t)
236 wantCommands(t, r.drain(), ports.DataDragCancel{})236 wantCommands(t, r.drain(), ports.DataDragCancel{})
237 // The window drag goes on.237 // The window drag goes on.
@@ -252,7 +252,7 @@ func TestDataDragRefused(t *testing.T) {
252 d.client <- ports.SessionLockChanged{State: locked}252 d.client <- ports.SessionLockChanged{State: locked}
253 d.settle(t)253 d.settle(t)
254 d.drain()254 d.drain()
255- d.client <- ports.DataDragStarted{}255+ d.client <- ports.DataDragStarted{Button: 0x110}
256 d.settle(t)256 d.settle(t)
257 var got []ports.ClientCommand257 var got []ports.ClientCommand
258 for _, v := range d.drain() {258 for _, v := range d.drain() {
@@ -267,7 +267,7 @@ func TestDataDragRefused(t *testing.T) {
267 267
268 func TestDataDragCommandsAreSecurityWrapped(t *testing.T) {268 func TestDataDragCommandsAreSecurityWrapped(t *testing.T) {
269 d := startDnd(t)269 d := startDnd(t)
270- d.client <- ports.DataDragStarted{}270+ d.client <- ports.DataDragStarted{Button: 0x110}
271 d.settle(t)271 d.settle(t)
272 n := 0272 n := 0
273 for {273 for {
@@ -446,7 +446,7 @@ func TestDataDragFocusScreenFollowsPointer(t *testing.T) {
446 }446 }
447 r.in(ports.PointerButton{Button: 0x110, Pressed: true})447 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
448 r.settle(t)448 r.settle(t)
449- r.client <- ports.DataDragStarted{}449+ r.client <- ports.DataDragStarted{Button: 0x110}
450 r.moveTo(1500, 300, 3)450 r.moveTo(1500, 300, 3)
451 set = r.settle(t)451 set = r.settle(t)
452 if w, _ := windowIn(set, "B", 3); !w.Focused {452 if w, _ := windowIn(set, "B", 3); !w.Focused {
@@ -492,6 +492,49 @@ func TestDataDragFollowsWorkspaceSwitchUnderStillCursor(t *testing.T) {
492 }492 }
493 }493 }
494 494
495+func TestDataDragTargetMovedUnderStillCursor(t *testing.T) {
496+ d := startDnd(t)
497+ d.start(t, 0)
498+ // Over 2, focused by a click-free keyboard move, then cmd+f maximizes
499+ // its column: still the target under the cursor, at another point.
500+ d.moveTo(float64(d.two.X+d.two.W-20), float64(d.two.Y+30), 2)
501+ d.in(ports.KeyEvent{Keysym: "l", Keycode: 38, Mods: ports.ModSuper, Pressed: true})
502+ d.in(ports.KeyEvent{Keysym: "l", Keycode: 38, Mods: ports.ModSuper})
503+ d.settle(t)
504+ d.drain()
505+ d.in(ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
506+ d.in(ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper})
507+ set := d.settle(t)
508+ wide := dragRect(t, set, dragOut.Name, 2)
509+ if wide.X == d.two.X {
510+ t.Fatalf("the column did not move: %v", wide)
511+ }
512+ x, y := float64(d.two.X+d.two.W-20-wide.X), float64(d.two.Y+30-wide.Y)
513+ got := dataDragCommands(d.drain())
514+ if !slices.Contains(got, ports.ClientCommand(ports.DataDragMotion{ID: 2, X: x, Y: y, Time: 2 * time.Millisecond})) {
515+ t.Fatalf("no motion to %v,%v: %#v", x, y, got)
516+ }
517+}
518+
519+func TestDataDragDropsOnItsOwnButton(t *testing.T) {
520+ d := startDnd(t)
521+ // Right pressed after left: the drag is left's, the grab's.
522+ d.in(ports.PointerButton{Button: 0x111, Pressed: true})
523+ d.settle(t)
524+ d.drain()
525+ d.start(t, 0)
526+ d.in(ports.PointerButton{Button: 0x111})
527+ d.settle(t)
528+ if got := dataDragCommands(d.drain()); slices.Contains(got, ports.ClientCommand(ports.DataDragDrop{})) {
529+ t.Fatalf("right's release dropped: %#v", got)
530+ }
531+ d.in(ports.PointerButton{Button: 0x110})
532+ d.settle(t)
533+ if got := dataDragCommands(d.drain()); len(got) == 0 || got[0] != (ports.DataDragDrop{}) {
534+ t.Fatalf("left's release did not drop: %#v", got)
535+ }
536+}
537+
495 func TestDataDragEscapeCancelsOverSwitcher(t *testing.T) {538 func TestDataDragEscapeCancelsOverSwitcher(t *testing.T) {
496 d := startDnd(t)539 d := startDnd(t)
497 d.start(t, 0)540 d.start(t, 0)
M internal/core/input.go +3 -2
@@ -172,8 +172,9 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
172172 return err
173173 }
174174 }
175+ grab := v.Pressed && c.held() == 0
175176 if v.Pressed {
176- if c.held() == 0 {
177+ if grab {
177178 c.grab = id
178179 }
179180 c.buttons[v.Button] = true
@@ -182,7 +183,7 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
182183 delete(c.buttons, v.Button)
183184 }
184185 if id != 0 {
185- if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, Time: v.Time}); err != nil {
186+ if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, Grab: grab, Time: v.Time}); err != nil {
186187 return err
187188 }
188189 // A click on an on-demand layer gives it the keyboard.
@@ -172,8 +172,9 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
172 return err172 return err
173 }173 }
174 }174 }
175+ grab := v.Pressed && c.held() == 0
175 if v.Pressed {176 if v.Pressed {
176- if c.held() == 0 {177+ if grab {
177 c.grab = id178 c.grab = id
178 }179 }
179 c.buttons[v.Button] = true180 c.buttons[v.Button] = true
@@ -182,7 +183,7 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
182 delete(c.buttons, v.Button)183 delete(c.buttons, v.Button)
183 }184 }
184 if id != 0 {185 if id != 0 {
185- if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, Time: v.Time}); err != nil {186+ if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, Grab: grab, Time: v.Time}); err != nil {
186 return err187 return err
187 }188 }
188 // A click on an on-demand layer gives it the keyboard.189 // A click on an on-demand layer gives it the keyboard.
M internal/ports/client_commands.go +3 -0
@@ -178,6 +178,9 @@ type PointerButtonTo struct {
178178 ID WindowID
179179 Button uint32
180180 Pressed bool
181+ // Grab marks the press that starts the implicit grab: no other button
182+ // was held. start_drag must name its serial.
183+ Grab bool
181184 // Time is the device timestamp (CLOCK_MONOTONIC).
182185 Time time.Duration
183186 }
@@ -178,6 +178,9 @@ type PointerButtonTo struct {
178 ID WindowID178 ID WindowID
179 Button uint32179 Button uint32
180 Pressed bool180 Pressed bool
181+ // Grab marks the press that starts the implicit grab: no other button
182+ // was held. start_drag must name its serial.
183+ Grab bool
181 // Time is the device timestamp (CLOCK_MONOTONIC).184 // Time is the device timestamp (CLOCK_MONOTONIC).
182 Time time.Duration185 Time time.Duration
183 }186 }
M internal/ports/dnd.go +5 -3
@@ -10,10 +10,12 @@ import "time"
1010 // everything at the cursor.
1111
1212 // DataDragStarted carries wayland → core a client drag answering the
13-// client's current press. Icon is the drag icon's ID (0: none); core
14-// answers with DataDragCancel when it refuses the drag.
13+// client's current press. Button is the button of that press, whose
14+// release drops; Icon is the drag icon's ID (0: none). Core answers with
15+// DataDragCancel when it refuses the drag.
1516 type DataDragStarted struct {
16- Icon WindowID
17+ Button uint32
18+ Icon WindowID
1719 }
1820
1921 func (DataDragStarted) clientEvent() {}
@@ -10,10 +10,12 @@ import "time"
10 // everything at the cursor.10 // everything at the cursor.
11 11
12 // DataDragStarted carries wayland → core a client drag answering the12 // DataDragStarted carries wayland → core a client drag answering the
13-// client's current press. Icon is the drag icon's ID (0: none); core13+// client's current press. Button is the button of that press, whose
14-// answers with DataDragCancel when it refuses the drag.14+// release drops; Icon is the drag icon's ID (0: none). Core answers with
15+// DataDragCancel when it refuses the drag.
15 type DataDragStarted struct {16 type DataDragStarted struct {
16- Icon WindowID17+ Button uint32
18+ Icon WindowID
17 }19 }
18 20
19 func (DataDragStarted) clientEvent() {}21 func (DataDragStarted) clientEvent() {}

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