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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 228 | 228 | } | |
| 229 | 229 | ||
| 230 | 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 | 238 | func (c *Core) endDrag(ctx context.Context) error { | |
| 232 | 239 | d := c.drag | |
| 233 | 240 | c.drag = nil | |
| 234 | 241 | if !d.float { | |
| 235 | 242 | if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone { | |
| 243 | + | now := c.now() | |
| 244 | + | shots := c.snapshot(now) | |
| 236 | 245 | c.drop(d.id, t) | |
| 237 | 246 | // Slots and guests follow the windows, as after a bind. | |
| 238 | 247 | if err := c.workspaceVisible(ctx, false); err != nil { | |
| 239 | 248 | return err | |
| 240 | 249 | } | |
| 250 | + | c.transition(shots, now) | |
| 241 | 251 | } | |
| 242 | 252 | } | |
| 243 | 253 | 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.drag | 239 | d := c.drag | ||
| 233 | c.drag = nil | 240 | 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 err | 248 | 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) { | |||
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | 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 | 309 | for id := ports.WindowID(1); id <= 3; id++ { | |
| 305 | 310 | r.client <- ports.WindowMapped{ID: id} | |
| 306 | 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 | 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 | 544 | for id := ports.WindowID(1); id <= 2; id++ { | |
| 438 | 545 | r.client <- ports.WindowMapped{ID: id} | |
| 439 | 546 | r.settle(t) | |
| @@ -594,7 +701,12 @@ func dragEdgeScroll(t *testing.T, r *dragRig) { | |||
| 594 | 701 | } | |
| 595 | 702 | ||
| 596 | 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 | 710 | for id := ports.WindowID(1); id <= 3; id++ { | |
| 599 | 711 | r.client <- ports.WindowMapped{ID: id} | |
| 600 | 712 | 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) | ||