detached 63a69c1

feat(core): slide a dropped tile and re-flow its neighbours on drag release

commit
63a69c10932b9742b4df864cdf572f75e5b00671
author
brice · · 2026-10-04 22:40 UTC
parent
7d2748c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +125 -3
M internal/core/drag.go +10 -0
@@ -228,16 +228,26 @@ func resized(r Rect, edges ports.ResizeEdges, dx, dy, least int, u Rect) Rect {
228228 }
229229
230230 // endDrag drops the window on release: a tile lands on its target.
231+//
232+// The drop is a transition like a bind's: the snapshot before it is what
233+// the screens draw, the transition after it slides the dropped tile and
234+// re-flows its neighbours. A tile does not follow the pointer during the
235+// drag (dragMotion): the scene draws it in its slot with the drop hints, so
236+// the slot is where the user last saw it and where it slides from. A float
237+// follows the pointer and is already where it lands: no transition.
231238 func (c *Core) endDrag(ctx context.Context) error {
232239 d := c.drag
233240 c.drag = nil
234241 if !d.float {
235242 if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone {
243+ now := c.now()
244+ shots := c.snapshot(now)
236245 c.drop(d.id, t)
237246 // Slots and guests follow the windows, as after a bind.
238247 if err := c.workspaceVisible(ctx, false); err != nil {
239248 return err
240249 }
250+ c.transition(shots, now)
241251 }
242252 }
243253 if err := c.publish(ctx); err != nil {
@@ -228,16 +228,26 @@ func resized(r Rect, edges ports.ResizeEdges, dx, dy, least int, u Rect) Rect {
228 }228 }
229 229
230 // endDrag drops the window on release: a tile lands on its target.230 // endDrag drops the window on release: a tile lands on its target.
231+//
232+// The drop is a transition like a bind's: the snapshot before it is what
233+// the screens draw, the transition after it slides the dropped tile and
234+// re-flows its neighbours. A tile does not follow the pointer during the
235+// drag (dragMotion): the scene draws it in its slot with the drop hints, so
236+// the slot is where the user last saw it and where it slides from. A float
237+// follows the pointer and is already where it lands: no transition.
231 func (c *Core) endDrag(ctx context.Context) error {238 func (c *Core) endDrag(ctx context.Context) error {
232 d := c.drag239 d := c.drag
233 c.drag = nil240 c.drag = nil
234 if !d.float {241 if !d.float {
235 if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone {242 if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone {
243+ now := c.now()
244+ shots := c.snapshot(now)
236 c.drop(d.id, t)245 c.drop(d.id, t)
237 // Slots and guests follow the windows, as after a bind.246 // Slots and guests follow the windows, as after a bind.
238 if err := c.workspaceVisible(ctx, false); err != nil {247 if err := c.workspaceVisible(ctx, false); err != nil {
239 return err248 return err
240 }249 }
250+ c.transition(shots, now)
241 }251 }
242 }252 }
243 if err := c.publish(ctx); err != nil {253 if err := c.publish(ctx); err != nil {
M internal/core/drag_test.go +115 -3
@@ -300,7 +300,12 @@ func TestDragEscapeRestoresFloat(t *testing.T) {
300300 }
301301
302302 func TestDragTileZonesAndHints(t *testing.T) {
303- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
303+ both(t, func(t *testing.T, animated bool) {
304+ dragTileZonesAndHints(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
305+ })
306+}
307+
308+func dragTileZonesAndHints(t *testing.T, r *dragRig) {
304309 for id := ports.WindowID(1); id <= 3; id++ {
305310 r.client <- ports.WindowMapped{ID: id}
306311 r.settle(t)
@@ -432,8 +437,110 @@ func dragTileGapInsert(t *testing.T, r *dragRig) {
432437 }
433438 }
434439
440+// release sends the button release and returns the scene set core
441+// publishes for it, without moving the clock (settle would).
442+func (r *dragRig) release(t *testing.T) []ports.Scene {
443+ t.Helper()
444+ r.in(ports.PointerButton{Button: 0x110})
445+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
446+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
447+ select {
448+ case r.last = <-r.scenes:
449+ case <-time.After(5 * time.Second):
450+ t.Fatal("no scene")
451+ }
452+ return r.last
453+}
454+
455+// A dropped tile slides from where the drag scene drew it (its own slot:
456+// a tile does not follow the pointer) and its neighbours re-flow; the
457+// client gets one configure, for the final size. With animations off the
458+// drop is instant.
459+func TestDragDropTileSlides(t *testing.T) {
460+ both(t, func(t *testing.T, animated bool) {
461+ r := startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
462+ for id := ports.WindowID(1); id <= 3; id++ {
463+ r.client <- ports.WindowMapped{ID: id}
464+ r.settle(t)
465+ }
466+ set := r.settle(t)
467+ one0, two0, three0 := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
468+ r.moveTo(float64(one0.X+one0.W/2), float64(one0.Y+one0.H/2), 1)
469+ r.settle(t)
470+ r.press(t, 0x110, ports.ModSuper)
471+ // The bottom band of 3: 1 stacks below it.
472+ r.moveTo(float64(three0.X+three0.W/2), float64(three0.Y+three0.H-5), 2)
473+ set = r.settle(t)
474+ if got := dragRect(t, set, "A", 1); got != one0 {
475+ t.Fatalf("tile left its slot during the drag: %+v, was %+v", got, one0)
476+ }
477+ r.drain()
478+ set = r.release(t)
479+ if len(dragScene(set, "A").DropHints) != 0 {
480+ t.Fatal("hints left")
481+ }
482+ if !animated {
483+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
484+ t.Fatal(got)
485+ }
486+ // Instant: the scene right after the drop is the settled one.
487+ if got := dragRect(t, set, "A", 1); got == one0 || got.X != dragRect(t, r.settle(t), "A", 1).X {
488+ t.Fatalf("not dropped at once: %+v", got)
489+ }
490+ return
491+ }
492+ // Right after the release the windows are still where the drag
493+ // scene drew them.
494+ for id, want := range map[ports.WindowID]ports.Rect{1: one0, 2: two0, 3: three0} {
495+ if got := dragRect(t, set, "A", id); got != want {
496+ t.Fatalf("window %d at %+v right after the drop, want its old %+v", id, got, want)
497+ }
498+ }
499+ mid, ok := r.clock.flip(t, r.frames, r.scenes, 30*time.Millisecond, "A")
500+ if !ok {
501+ t.Fatal("no frame after the drop: nothing animates")
502+ }
503+ r.lands++
504+ // The dropped tile is between its old slot and its new one, and the
505+ // column it joined re-flows too.
506+ got := dragRect(t, mid, "A", 1)
507+ if got == one0 || got.X <= one0.X && got.Y <= one0.Y {
508+ t.Fatalf("window 1 at %+v on the first frame, still at its old %+v", got, one0)
509+ }
510+ if got2 := dragRect(t, mid, "A", 2); got2 == two0 {
511+ t.Fatalf("neighbour 2 did not start moving: %+v", got2)
512+ }
513+ set = r.settle(t)
514+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
515+ t.Fatal(got)
516+ }
517+ want := map[ports.WindowID]ports.Rect{}
518+ for id := ports.WindowID(1); id <= 3; id++ {
519+ want[id] = dragRect(t, set, "A", id)
520+ }
521+ 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)
523+ }
524+ // One configure for the dropped tile, to its final size.
525+ var sizes [][2]int
526+ 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})
529+ }
530+ }
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)
533+ }
534+ })
535+}
536+
435537 func TestDragFixedNoGapZone(t *testing.T) {
436- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
538+ both(t, func(t *testing.T, animated bool) {
539+ dragFixedNoGapZone(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
540+ })
541+}
542+
543+func dragFixedNoGapZone(t *testing.T, r *dragRig) {
437544 for id := ports.WindowID(1); id <= 2; id++ {
438545 r.client <- ports.WindowMapped{ID: id}
439546 r.settle(t)
@@ -594,7 +701,12 @@ func dragEdgeScroll(t *testing.T, r *dragRig) {
594701 }
595702
596703 func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
597- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
704+ both(t, func(t *testing.T, animated bool) {
705+ dragFullFixedStacksInOutlinedColumn(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
706+ })
707+}
708+
709+func dragFullFixedStacksInOutlinedColumn(t *testing.T, r *dragRig) {
598710 for id := ports.WindowID(1); id <= 3; id++ {
599711 r.client <- ports.WindowMapped{ID: id}
600712 r.settle(t)
@@ -300,7 +300,12 @@ func TestDragEscapeRestoresFloat(t *testing.T) {
300 }300 }
301 301
302 func TestDragTileZonesAndHints(t *testing.T) {302 func TestDragTileZonesAndHints(t *testing.T) {
303- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)303+ both(t, func(t *testing.T, animated bool) {
304+ dragTileZonesAndHints(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
305+ })
306+}
307+
308+func dragTileZonesAndHints(t *testing.T, r *dragRig) {
304 for id := ports.WindowID(1); id <= 3; id++ {309 for id := ports.WindowID(1); id <= 3; id++ {
305 r.client <- ports.WindowMapped{ID: id}310 r.client <- ports.WindowMapped{ID: id}
306 r.settle(t)311 r.settle(t)
@@ -432,8 +437,110 @@ func dragTileGapInsert(t *testing.T, r *dragRig) {
432 }437 }
433 }438 }
434 439
440+// release sends the button release and returns the scene set core
441+// publishes for it, without moving the clock (settle would).
442+func (r *dragRig) release(t *testing.T) []ports.Scene {
443+ t.Helper()
444+ r.in(ports.PointerButton{Button: 0x110})
445+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
446+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
447+ select {
448+ case r.last = <-r.scenes:
449+ case <-time.After(5 * time.Second):
450+ t.Fatal("no scene")
451+ }
452+ return r.last
453+}
454+
455+// A dropped tile slides from where the drag scene drew it (its own slot:
456+// a tile does not follow the pointer) and its neighbours re-flow; the
457+// client gets one configure, for the final size. With animations off the
458+// drop is instant.
459+func TestDragDropTileSlides(t *testing.T) {
460+ both(t, func(t *testing.T, animated bool) {
461+ r := startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
462+ for id := ports.WindowID(1); id <= 3; id++ {
463+ r.client <- ports.WindowMapped{ID: id}
464+ r.settle(t)
465+ }
466+ set := r.settle(t)
467+ one0, two0, three0 := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
468+ r.moveTo(float64(one0.X+one0.W/2), float64(one0.Y+one0.H/2), 1)
469+ r.settle(t)
470+ r.press(t, 0x110, ports.ModSuper)
471+ // The bottom band of 3: 1 stacks below it.
472+ r.moveTo(float64(three0.X+three0.W/2), float64(three0.Y+three0.H-5), 2)
473+ set = r.settle(t)
474+ if got := dragRect(t, set, "A", 1); got != one0 {
475+ t.Fatalf("tile left its slot during the drag: %+v, was %+v", got, one0)
476+ }
477+ r.drain()
478+ set = r.release(t)
479+ if len(dragScene(set, "A").DropHints) != 0 {
480+ t.Fatal("hints left")
481+ }
482+ if !animated {
483+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
484+ t.Fatal(got)
485+ }
486+ // Instant: the scene right after the drop is the settled one.
487+ if got := dragRect(t, set, "A", 1); got == one0 || got.X != dragRect(t, r.settle(t), "A", 1).X {
488+ t.Fatalf("not dropped at once: %+v", got)
489+ }
490+ return
491+ }
492+ // Right after the release the windows are still where the drag
493+ // scene drew them.
494+ for id, want := range map[ports.WindowID]ports.Rect{1: one0, 2: two0, 3: three0} {
495+ if got := dragRect(t, set, "A", id); got != want {
496+ t.Fatalf("window %d at %+v right after the drop, want its old %+v", id, got, want)
497+ }
498+ }
499+ mid, ok := r.clock.flip(t, r.frames, r.scenes, 30*time.Millisecond, "A")
500+ if !ok {
501+ t.Fatal("no frame after the drop: nothing animates")
502+ }
503+ r.lands++
504+ // The dropped tile is between its old slot and its new one, and the
505+ // column it joined re-flows too.
506+ got := dragRect(t, mid, "A", 1)
507+ if got == one0 || got.X <= one0.X && got.Y <= one0.Y {
508+ t.Fatalf("window 1 at %+v on the first frame, still at its old %+v", got, one0)
509+ }
510+ if got2 := dragRect(t, mid, "A", 2); got2 == two0 {
511+ t.Fatalf("neighbour 2 did not start moving: %+v", got2)
512+ }
513+ set = r.settle(t)
514+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
515+ t.Fatal(got)
516+ }
517+ want := map[ports.WindowID]ports.Rect{}
518+ for id := ports.WindowID(1); id <= 3; id++ {
519+ want[id] = dragRect(t, set, "A", id)
520+ }
521+ 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)
523+ }
524+ // One configure for the dropped tile, to its final size.
525+ var sizes [][2]int
526+ 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})
529+ }
530+ }
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)
533+ }
534+ })
535+}
536+
435 func TestDragFixedNoGapZone(t *testing.T) {537 func TestDragFixedNoGapZone(t *testing.T) {
436- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)538+ both(t, func(t *testing.T, animated bool) {
539+ dragFixedNoGapZone(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
540+ })
541+}
542+
543+func dragFixedNoGapZone(t *testing.T, r *dragRig) {
437 for id := ports.WindowID(1); id <= 2; id++ {544 for id := ports.WindowID(1); id <= 2; id++ {
438 r.client <- ports.WindowMapped{ID: id}545 r.client <- ports.WindowMapped{ID: id}
439 r.settle(t)546 r.settle(t)
@@ -594,7 +701,12 @@ func dragEdgeScroll(t *testing.T, r *dragRig) {
594 }701 }
595 702
596 func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {703 func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
597- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)704+ both(t, func(t *testing.T, animated bool) {
705+ dragFullFixedStacksInOutlinedColumn(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
706+ })
707+}
708+
709+func dragFullFixedStacksInOutlinedColumn(t *testing.T, r *dragRig) {
598 for id := ports.WindowID(1); id <= 3; id++ {710 for id := ports.WindowID(1); id <= 3; id++ {
599 r.client <- ports.WindowMapped{ID: id}711 r.client <- ports.WindowMapped{ID: id}
600 r.settle(t)712 r.settle(t)

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