detached ecc78a7

test(core): follow the bind order on drop, guard the drop's own spring, check every configure, animate the cross-output drop

commit
ecc78a71ea294e930f0200487d72594c6cbf2437
author
brice · · 2026-10-04 22:45 UTC
parent
63a69c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +30 -9
M internal/core/drag.go +1 -1
@@ -243,11 +243,11 @@ func (c *Core) endDrag(ctx context.Context) error {
243243 now := c.now()
244244 shots := c.snapshot(now)
245245 c.drop(d.id, t)
246+ c.transition(shots, now)
246247 // Slots and guests follow the windows, as after a bind.
247248 if err := c.workspaceVisible(ctx, false); err != nil {
248249 return err
249250 }
250- c.transition(shots, now)
251251 }
252252 }
253253 if err := c.publish(ctx); err != nil {
@@ -243,11 +243,11 @@ func (c *Core) endDrag(ctx context.Context) error {
243 now := c.now()243 now := c.now()
244 shots := c.snapshot(now)244 shots := c.snapshot(now)
245 c.drop(d.id, t)245 c.drop(d.id, t)
246+ c.transition(shots, now)
246 // Slots and guests follow the windows, as after a bind.247 // Slots and guests follow the windows, as after a bind.
247 if err := c.workspaceVisible(ctx, false); err != nil {248 if err := c.workspaceVisible(ctx, false); err != nil {
248 return err249 return err
249 }250 }
250- c.transition(shots, now)
251 }251 }
252 }252 }
253 if err := c.publish(ctx); err != nil {253 if err := c.publish(ctx); err != nil {
M internal/core/drag_test.go +29 -8
@@ -329,6 +329,8 @@ func dragTileZonesAndHints(t *testing.T, r *dragRig) {
329329 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
330330 t.Fatal(got)
331331 }
332+ // The drop itself must run a spring: the guard counts from here.
333+ r.lands = 0
332334 r.in(ports.PointerButton{Button: 0x110})
333335 set = r.settle(t)
334336 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
@@ -348,6 +350,8 @@ func dragTileZonesAndHints(t *testing.T, r *dragRig) {
348350 if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {
349351 t.Fatalf("swap outline %+v", hints)
350352 }
353+ // The drop itself must run a spring: the guard counts from here.
354+ r.lands = 0
351355 r.in(ports.PointerButton{Button: 0x110})
352356 set = r.settle(t)
353357 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
@@ -360,6 +364,8 @@ func dragTileZonesAndHints(t *testing.T, r *dragRig) {
360364 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
361365 r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)
362366 r.settle(t)
367+ // The drop itself must run a spring: the guard counts from here.
368+ r.lands = 0
363369 r.in(ports.PointerButton{Button: 0x110})
364370 set = r.settle(t)
365371 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {
@@ -521,15 +527,19 @@ func TestDragDropTileSlides(t *testing.T) {
521527 if want[1].Y <= want[3].Y || want[1].X != want[3].X {
522528 t.Fatalf("1 is not stacked below 3 once settled: %+v", want)
523529 }
524- // One configure for the dropped tile, to its final size.
525- var sizes [][2]int
530+ // A window is configured at most once by the drop, to its settled
531+ // size, never to a size along the slide; the dropped tile always.
532+ sizes := map[ports.WindowID][][2]int{}
526533 for _, v := range r.drain() {
527- if v, ok := v.(ports.ConfigureWindow); ok && v.ID == 1 {
528- sizes = append(sizes, [2]int{v.Width, v.Height})
534+ if v, ok := v.(ports.ConfigureWindow); ok {
535+ sizes[v.ID] = append(sizes[v.ID], [2]int{v.Width, v.Height})
529536 }
530537 }
531- if len(sizes) != 1 || sizes[0] != [2]int{want[1].W, want[1].H} {
532- t.Fatalf("configures of window 1 %v, want exactly [%dx%d]", sizes, want[1].W, want[1].H)
538+ for id, rect := range want {
539+ got := sizes[id]
540+ if len(got) > 1 || len(got) == 1 && got[0] != [2]int{rect.W, rect.H} || id == 1 && len(got) != 1 {
541+ t.Fatalf("configures of window %d %v, want at most one at %dx%d (exactly one for window 1)", id, got, rect.W, rect.H)
542+ }
533543 }
534544 })
535545 }
@@ -557,6 +567,8 @@ func dragFixedNoGapZone(t *testing.T, r *dragRig) {
557567 // Top band of 2 stacks 1 above it.
558568 r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)
559569 r.settle(t)
570+ // The drop itself must run a spring: the guard counts from here.
571+ r.lands = 0
560572 r.in(ports.PointerButton{Button: 0x110})
561573 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {
562574 t.Fatal(got)
@@ -564,8 +576,12 @@ func dragFixedNoGapZone(t *testing.T, r *dragRig) {
564576 }
565577
566578 func TestDragAcrossOutputs(t *testing.T) {
567- b := ports.OutputInfo{Name: "B", Width: 800, Height: 600}
568- r := startDragRig(t, nil, dragOut, b)
579+ both(t, func(t *testing.T, animated bool) {
580+ dragAcrossOutputs(t, startDragMode(t, animated, nil, dragOut, ports.OutputInfo{Name: "B", Width: 800, Height: 600}))
581+ })
582+}
583+
584+func dragAcrossOutputs(t *testing.T, r *dragRig) {
569585 r.client <- ports.WindowMapped{ID: 1}
570586 r.client <- ports.WindowMapped{ID: 2}
571587 r.settle(t)
@@ -586,6 +602,7 @@ func TestDragAcrossOutputs(t *testing.T) {
586602 if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {
587603 t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)
588604 }
605+ r.lands = 0
589606 r.in(ports.PointerButton{Button: 0x110})
590607 set = r.settle(t)
591608 if _, ok := windowIn(set, "B", 1); !ok {
@@ -722,6 +739,8 @@ func dragFullFixedStacksInOutlinedColumn(t *testing.T, r *dragRig) {
722739 r.press(t, 0x110, ports.ModSuper)
723740 r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)
724741 r.settle(t)
742+ // The drop itself must run a spring: the guard counts from here.
743+ r.lands = 0
725744 r.in(ports.PointerButton{Button: 0x110})
726745 set = r.settle(t)
727746 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {
@@ -733,6 +752,8 @@ func dragFullFixedStacksInOutlinedColumn(t *testing.T, r *dragRig) {
733752 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
734753 r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)
735754 r.settle(t)
755+ // The drop itself must run a spring: the guard counts from here.
756+ r.lands = 0
736757 r.in(ports.PointerButton{Button: 0x110})
737758 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {
738759 t.Fatal(got)
@@ -329,6 +329,8 @@ func dragTileZonesAndHints(t *testing.T, r *dragRig) {
329 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {329 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
330 t.Fatal(got)330 t.Fatal(got)
331 }331 }
332+ // The drop itself must run a spring: the guard counts from here.
333+ r.lands = 0
332 r.in(ports.PointerButton{Button: 0x110})334 r.in(ports.PointerButton{Button: 0x110})
333 set = r.settle(t)335 set = r.settle(t)
334 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {336 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
@@ -348,6 +350,8 @@ func dragTileZonesAndHints(t *testing.T, r *dragRig) {
348 if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {350 if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {
349 t.Fatalf("swap outline %+v", hints)351 t.Fatalf("swap outline %+v", hints)
350 }352 }
353+ // The drop itself must run a spring: the guard counts from here.
354+ r.lands = 0
351 r.in(ports.PointerButton{Button: 0x110})355 r.in(ports.PointerButton{Button: 0x110})
352 set = r.settle(t)356 set = r.settle(t)
353 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {357 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
@@ -360,6 +364,8 @@ func dragTileZonesAndHints(t *testing.T, r *dragRig) {
360 r.in(ports.PointerButton{Button: 0x110, Pressed: true})364 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
361 r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)365 r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)
362 r.settle(t)366 r.settle(t)
367+ // The drop itself must run a spring: the guard counts from here.
368+ r.lands = 0
363 r.in(ports.PointerButton{Button: 0x110})369 r.in(ports.PointerButton{Button: 0x110})
364 set = r.settle(t)370 set = r.settle(t)
365 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {371 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {
@@ -521,15 +527,19 @@ func TestDragDropTileSlides(t *testing.T) {
521 if want[1].Y <= want[3].Y || want[1].X != want[3].X {527 if want[1].Y <= want[3].Y || want[1].X != want[3].X {
522 t.Fatalf("1 is not stacked below 3 once settled: %+v", want)528 t.Fatalf("1 is not stacked below 3 once settled: %+v", want)
523 }529 }
524- // One configure for the dropped tile, to its final size.530+ // A window is configured at most once by the drop, to its settled
525- var sizes [][2]int531+ // size, never to a size along the slide; the dropped tile always.
532+ sizes := map[ports.WindowID][][2]int{}
526 for _, v := range r.drain() {533 for _, v := range r.drain() {
527- if v, ok := v.(ports.ConfigureWindow); ok && v.ID == 1 {534+ if v, ok := v.(ports.ConfigureWindow); ok {
528- sizes = append(sizes, [2]int{v.Width, v.Height})535+ sizes[v.ID] = append(sizes[v.ID], [2]int{v.Width, v.Height})
529 }536 }
530 }537 }
531- if len(sizes) != 1 || sizes[0] != [2]int{want[1].W, want[1].H} {538+ for id, rect := range want {
532- t.Fatalf("configures of window 1 %v, want exactly [%dx%d]", sizes, want[1].W, want[1].H)539+ got := sizes[id]
540+ if len(got) > 1 || len(got) == 1 && got[0] != [2]int{rect.W, rect.H} || id == 1 && len(got) != 1 {
541+ t.Fatalf("configures of window %d %v, want at most one at %dx%d (exactly one for window 1)", id, got, rect.W, rect.H)
542+ }
533 }543 }
534 })544 })
535 }545 }
@@ -557,6 +567,8 @@ func dragFixedNoGapZone(t *testing.T, r *dragRig) {
557 // Top band of 2 stacks 1 above it.567 // Top band of 2 stacks 1 above it.
558 r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)568 r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)
559 r.settle(t)569 r.settle(t)
570+ // The drop itself must run a spring: the guard counts from here.
571+ r.lands = 0
560 r.in(ports.PointerButton{Button: 0x110})572 r.in(ports.PointerButton{Button: 0x110})
561 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {573 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {
562 t.Fatal(got)574 t.Fatal(got)
@@ -564,8 +576,12 @@ func dragFixedNoGapZone(t *testing.T, r *dragRig) {
564 }576 }
565 577
566 func TestDragAcrossOutputs(t *testing.T) {578 func TestDragAcrossOutputs(t *testing.T) {
567- b := ports.OutputInfo{Name: "B", Width: 800, Height: 600}579+ both(t, func(t *testing.T, animated bool) {
568- r := startDragRig(t, nil, dragOut, b)580+ dragAcrossOutputs(t, startDragMode(t, animated, nil, dragOut, ports.OutputInfo{Name: "B", Width: 800, Height: 600}))
581+ })
582+}
583+
584+func dragAcrossOutputs(t *testing.T, r *dragRig) {
569 r.client <- ports.WindowMapped{ID: 1}585 r.client <- ports.WindowMapped{ID: 1}
570 r.client <- ports.WindowMapped{ID: 2}586 r.client <- ports.WindowMapped{ID: 2}
571 r.settle(t)587 r.settle(t)
@@ -586,6 +602,7 @@ func TestDragAcrossOutputs(t *testing.T) {
586 if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {602 if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {
587 t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)603 t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)
588 }604 }
605+ r.lands = 0
589 r.in(ports.PointerButton{Button: 0x110})606 r.in(ports.PointerButton{Button: 0x110})
590 set = r.settle(t)607 set = r.settle(t)
591 if _, ok := windowIn(set, "B", 1); !ok {608 if _, ok := windowIn(set, "B", 1); !ok {
@@ -722,6 +739,8 @@ func dragFullFixedStacksInOutlinedColumn(t *testing.T, r *dragRig) {
722 r.press(t, 0x110, ports.ModSuper)739 r.press(t, 0x110, ports.ModSuper)
723 r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)740 r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)
724 r.settle(t)741 r.settle(t)
742+ // The drop itself must run a spring: the guard counts from here.
743+ r.lands = 0
725 r.in(ports.PointerButton{Button: 0x110})744 r.in(ports.PointerButton{Button: 0x110})
726 set = r.settle(t)745 set = r.settle(t)
727 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {746 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {
@@ -733,6 +752,8 @@ func dragFullFixedStacksInOutlinedColumn(t *testing.T, r *dragRig) {
733 r.in(ports.PointerButton{Button: 0x110, Pressed: true})752 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
734 r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)753 r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)
735 r.settle(t)754 r.settle(t)
755+ // The drop itself must run a spring: the guard counts from here.
756+ r.lands = 0
736 r.in(ports.PointerButton{Button: 0x110})757 r.in(ports.PointerButton{Button: 0x110})
737 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {758 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {
738 t.Fatal(got)759 t.Fatal(got)

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