detached f5f82f7

feat(core): move and resize windows with the pointer (#83)

Cmd+left drag moves a window, cmd+right drag resizes a float, and xdg_toplevel move/resize start the same drag. Tiles drop above, below, onto or between columns, on any output, with drop hints drawn in the active border color; Escape cancels.
commit
f5f82f7ca69fb1972be3a750e61d20dd7ea76580
author
bnema · · 2026-10-01 16:40 UTC
committer
GitHub · · 2026-10-01 16:40 UTC
parent
6a1f994
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
16 files · +1790 -29
M docs/config.md +12 -0
@@ -155,6 +155,18 @@ Native floating windows are not in the stash: they stay centred in the usable ou
155155
156156 On a free floating window, `move-column-left/right` and `move-window-up/down` move it by 50 logical pixels, and `set-column-width`/`set-window-height` resize it by a share of the usable width or height. `toggle-window-stash` stashes it and brings it back floating at the same place. `toggle-floating` returns it to its column.
157157
158+### Moving windows with the pointer
159+
160+Hold `cmd` and drag with the left button to move the window under the pointer; drag with the right button to resize a floating window from its bottom-right corner. Apps that drag their own title bar or edges (`xdg_toplevel.move`/`resize`) do the same without `cmd`. The window gets no pointer events while you drag, and `Escape` cancels the drag.
161+
162+A floating window follows the pointer and becomes a [free floating window](#free-floating-windows). A tile stays in place until you release the button. Until then, a bar or frame in `border.active` color shows where it will land:
163+
164+- top or bottom quarter of a window: stack it above or below that window;
165+- middle of a window: swap the two columns (a window from a stacked column becomes a new column there);
166+- in scroll overflow, the gap between two columns: a new column there. Moving the pointer against the left or right edge scrolls the workspace one column at a time.
167+
168+The drop can be on another monitor; focus follows the window. There, the middle of a window puts it in a new column at that place: only columns of the same workspace swap. A full `fixed` workspace has no room for a new column, so the window stacks in the outlined one.
169+
158170 ### Overview
159171
160172 `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card.
@@ -155,6 +155,18 @@ Native floating windows are not in the stash: they stay centred in the usable ou
155 155
156 On a free floating window, `move-column-left/right` and `move-window-up/down` move it by 50 logical pixels, and `set-column-width`/`set-window-height` resize it by a share of the usable width or height. `toggle-window-stash` stashes it and brings it back floating at the same place. `toggle-floating` returns it to its column.156 On a free floating window, `move-column-left/right` and `move-window-up/down` move it by 50 logical pixels, and `set-column-width`/`set-window-height` resize it by a share of the usable width or height. `toggle-window-stash` stashes it and brings it back floating at the same place. `toggle-floating` returns it to its column.
157 157
158+### Moving windows with the pointer
159+
160+Hold `cmd` and drag with the left button to move the window under the pointer; drag with the right button to resize a floating window from its bottom-right corner. Apps that drag their own title bar or edges (`xdg_toplevel.move`/`resize`) do the same without `cmd`. The window gets no pointer events while you drag, and `Escape` cancels the drag.
161+
162+A floating window follows the pointer and becomes a [free floating window](#free-floating-windows). A tile stays in place until you release the button. Until then, a bar or frame in `border.active` color shows where it will land:
163+
164+- top or bottom quarter of a window: stack it above or below that window;
165+- middle of a window: swap the two columns (a window from a stacked column becomes a new column there);
166+- in scroll overflow, the gap between two columns: a new column there. Moving the pointer against the left or right edge scrolls the workspace one column at a time.
167+
168+The drop can be on another monitor; focus follows the window. There, the middle of a window puts it in a new column at that place: only columns of the same workspace swap. A full `fixed` workspace has no room for a new column, so the window stacks in the outlined one.
169+
158 ### Overview170 ### Overview
159 171
160 `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card.172 `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card.
A internal/adapters/drm/drop_hint_test.go +23 -0
@@ -0,0 +1,23 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+func TestDropHintsBlockPlanes(t *testing.T) {
10+ s, c := overlayScene()
11+ s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
12+ if _, _, reason := overlayCandidate(s, c); reason != "drop_hint" {
13+ t.Fatalf("overlay reason %q", reason)
14+ }
15+ fs, fc := fullscreenScene()
16+ if _, reason := scanoutCandidate(fs, fc, 200, 100); reason == "drop_hint" {
17+ t.Fatal("no hints, still refused")
18+ }
19+ fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
20+ if _, reason := scanoutCandidate(fs, fc, 200, 100); reason != "drop_hint" {
21+ t.Fatalf("scanout reason %q", reason)
22+ }
23+}
@@ -0,0 +1,23 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+func TestDropHintsBlockPlanes(t *testing.T) {
10+ s, c := overlayScene()
11+ s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
12+ if _, _, reason := overlayCandidate(s, c); reason != "drop_hint" {
13+ t.Fatalf("overlay reason %q", reason)
14+ }
15+ fs, fc := fullscreenScene()
16+ if _, reason := scanoutCandidate(fs, fc, 200, 100); reason == "drop_hint" {
17+ t.Fatal("no hints, still refused")
18+ }
19+ fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
20+ if _, reason := scanoutCandidate(fs, fc, 200, 100); reason != "drop_hint" {
21+ t.Fatalf("scanout reason %q", reason)
22+ }
23+}
M internal/adapters/drm/overlay.go +4 -0
@@ -94,6 +94,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9494 // float on the plane would take the veil with it.
9595 return ports.SceneWindow{}, ports.SurfaceContent{}, "dim"
9696 }
97+ if len(s.DropHints) > 0 {
98+ // Drag hints are drawn over the windows.
99+ return ports.SceneWindow{}, ports.SurfaceContent{}, "drop_hint"
100+ }
97101 for _, l := range s.Layers {
98102 if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 {
99103 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
@@ -94,6 +94,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
94 // float on the plane would take the veil with it.94 // float on the plane would take the veil with it.
95 return ports.SceneWindow{}, ports.SurfaceContent{}, "dim"95 return ports.SceneWindow{}, ports.SurfaceContent{}, "dim"
96 }96 }
97+ if len(s.DropHints) > 0 {
98+ // Drag hints are drawn over the windows.
99+ return ports.SceneWindow{}, ports.SurfaceContent{}, "drop_hint"
100+ }
97 for _, l := range s.Layers {101 for _, l := range s.Layers {
98 if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 {102 if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 {
99 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"103 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
M internal/adapters/drm/scanout.go +4 -0
@@ -103,6 +103,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
103103 if full == nil {
104104 return ports.SurfaceContent{}, "no_fullscreen"
105105 }
106+ if len(s.DropHints) > 0 {
107+ // Drag hints are drawn over the window.
108+ return ports.SurfaceContent{}, "drop_hint"
109+ }
106110 if full.Dim > 0 {
107111 // Its veil is composed over it: the plane would show it bright.
108112 return ports.SurfaceContent{}, "dim"
@@ -103,6 +103,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
103 if full == nil {103 if full == nil {
104 return ports.SurfaceContent{}, "no_fullscreen"104 return ports.SurfaceContent{}, "no_fullscreen"
105 }105 }
106+ if len(s.DropHints) > 0 {
107+ // Drag hints are drawn over the window.
108+ return ports.SurfaceContent{}, "drop_hint"
109+ }
106 if full.Dim > 0 {110 if full.Dim > 0 {
107 // Its veil is composed over it: the plane would show it bright.111 // Its veil is composed over it: the plane would show it bright.
108 return ports.SurfaceContent{}, "dim"112 return ports.SurfaceContent{}, "dim"
M internal/adapters/vulkan/scene.go +15 -0
@@ -40,6 +40,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
4040 }
4141 w.windows()
4242 w.popups(false)
43+ w.dropHints()
4344 w.bounds = outputBounds
4445 w.layers(true)
4546 w.popups(true)
@@ -82,6 +83,20 @@ func (w *sceneWalk) captureIndicators() {
8283 }
8384 }
8485
86+// dropHints fills where a dragged tile would land (ports.Scene.DropHints),
87+// in the active border color, over the workspace's windows.
88+func (w *sceneWalk) dropHints() {
89+ if len(w.s.DropHints) == 0 {
90+ return
91+ }
92+ col := parseColor(w.s.Border.Active)
93+ for _, r := range w.s.DropHints {
94+ if r.W > 0 && r.H > 0 {
95+ w.fill(w.physRect(r.X, r.Y, r.W, r.H), col)
96+ }
97+ }
98+}
99+
85100 // pill fills a rounded square (physical pixels) with three rects: a full
86101 // middle and two bands narrowed by the corner radius.
87102 func (w *sceneWalk) pill(r image.Rectangle, c [3]uint8) {
@@ -40,6 +40,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
40 }40 }
41 w.windows()41 w.windows()
42 w.popups(false)42 w.popups(false)
43+ w.dropHints()
43 w.bounds = outputBounds44 w.bounds = outputBounds
44 w.layers(true)45 w.layers(true)
45 w.popups(true)46 w.popups(true)
@@ -82,6 +83,20 @@ func (w *sceneWalk) captureIndicators() {
82 }83 }
83 }84 }
84 85
86+// dropHints fills where a dragged tile would land (ports.Scene.DropHints),
87+// in the active border color, over the workspace's windows.
88+func (w *sceneWalk) dropHints() {
89+ if len(w.s.DropHints) == 0 {
90+ return
91+ }
92+ col := parseColor(w.s.Border.Active)
93+ for _, r := range w.s.DropHints {
94+ if r.W > 0 && r.H > 0 {
95+ w.fill(w.physRect(r.X, r.Y, r.W, r.H), col)
96+ }
97+ }
98+}
99+
85 // pill fills a rounded square (physical pixels) with three rects: a full100 // pill fills a rounded square (physical pixels) with three rects: a full
86 // middle and two bands narrowed by the corner radius.101 // middle and two bands narrowed by the corner radius.
87 func (w *sceneWalk) pill(r image.Rectangle, c [3]uint8) {102 func (w *sceneWalk) pill(r image.Rectangle, c [3]uint8) {
A internal/adapters/vulkan/scene_drop_hint_test.go +39 -0
@@ -0,0 +1,39 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestSceneDropHintsOrder(t *testing.T) {
11+ r := &Renderer{width: 80, height: 60}
12+ s := ports.Scene{
13+ Border: ports.Border{Active: "#ff0000"},
14+ Windows: []ports.SceneWindow{
15+ {ID: 1, Rect: ports.Rect{W: 40, H: 60}},
16+ {ID: 2, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}},
17+ },
18+ Layers: []ports.SceneLayer{{ID: 9, Layer: ports.LayerTop, Rect: ports.Rect{W: 80, H: 5}}},
19+ DropHints: []ports.Rect{{X: 38, W: 4, H: 60}, {W: 0, H: 10}},
20+ }
21+ got := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
22+ // tile, float, one hint (the empty one is skipped), then the top layer.
23+ hint := -1
24+ for i, d := range got {
25+ if d.pc.color == [4]float32{1, 0, 0, 1} {
26+ if hint >= 0 {
27+ t.Fatalf("two hints: %+v", got)
28+ }
29+ hint = i
30+ }
31+ }
32+ if hint != 2 || got[hint].pc.rect != [4]int32{38, 0, 42, 60} {
33+ t.Fatalf("hint at %d: %+v", hint, got)
34+ }
35+ s.DropHints = nil
36+ if n := len(r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))); n != len(got)-1 {
37+ t.Fatalf("draws without hints = %d, want %d", n, len(got)-1)
38+ }
39+}
@@ -0,0 +1,39 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestSceneDropHintsOrder(t *testing.T) {
11+ r := &Renderer{width: 80, height: 60}
12+ s := ports.Scene{
13+ Border: ports.Border{Active: "#ff0000"},
14+ Windows: []ports.SceneWindow{
15+ {ID: 1, Rect: ports.Rect{W: 40, H: 60}},
16+ {ID: 2, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}},
17+ },
18+ Layers: []ports.SceneLayer{{ID: 9, Layer: ports.LayerTop, Rect: ports.Rect{W: 80, H: 5}}},
19+ DropHints: []ports.Rect{{X: 38, W: 4, H: 60}, {W: 0, H: 10}},
20+ }
21+ got := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
22+ // tile, float, one hint (the empty one is skipped), then the top layer.
23+ hint := -1
24+ for i, d := range got {
25+ if d.pc.color == [4]float32{1, 0, 0, 1} {
26+ if hint >= 0 {
27+ t.Fatalf("two hints: %+v", got)
28+ }
29+ hint = i
30+ }
31+ }
32+ if hint != 2 || got[hint].pc.rect != [4]int32{38, 0, 42, 60} {
33+ t.Fatalf("hint at %d: %+v", hint, got)
34+ }
35+ s.DropHints = nil
36+ if n := len(r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))); n != len(got)-1 {
37+ t.Fatalf("draws without hints = %d, want %d", n, len(got)-1)
38+ }
39+}
A internal/adapters/wayland/move_request_test.go +139 -0
@@ -0,0 +1,139 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
11+ "github.com/bnema/wlturbo"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// pressSerials records the serial of each pointer button event.
16+type pressSerials struct {
17+ wlturbo.BaseProxy
18+ serials chan uint32
19+}
20+
21+func (p *pressSerials) Dispatch(e *wlturbo.Event) {
22+ if e.Opcode == uint16(wayland.PointerEventButton) {
23+ p.serials <- e.Uint32()
24+ }
25+}
26+
27+func TestToplevelMoveRequest(t *testing.T) {
28+ s, events, commands, dir := lifecycleServer(t)
29+ c := protocolClient(t, s, dir)
30+ seat := bindVersion(t, c, "wl_seat", 8)
31+ registerProtocol(t, c, seat)
32+ pointer := c.AllocateID()
33+ p := &pressSerials{serials: make(chan uint32, 4)}
34+ p.SetID(pointer)
35+ registerWireProxy(c, p)
36+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
37+ comp := bindProtocol(t, c, "wl_compositor")
38+ wm := bindProtocol(t, c, "xdg_wm_base")
39+ shm := bindProtocol(t, c, "wl_shm")
40+ registerProtocol(t, c, shm)
41+ surf, xdg := c.AllocateID(), c.AllocateID()
42+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
43+ registerProtocol(t, c, surf)
44+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
45+ configured := make(chan uint32, 1)
46+ xp := &configureProxy{serial: configured}
47+ xp.SetID(xdg)
48+ registerWireProxy(c, xp)
49+ top := c.AllocateID()
50+ registerProtocol(t, c, top)
51+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
52+ // Before mapping, a move request is ignored.
53+ requestProtocol(t, c, top, xdgshell.ToplevelRequestMove, seat, uint32(0))
54+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
55+ if err := c.Roundtrip(); err != nil {
56+ t.Fatal(err)
57+ }
58+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-configured)
59+ fd, err := unix.MemfdCreate("move-buffer", 0)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ defer unix.Close(fd)
64+ if err := unix.Ftruncate(fd, 4); err != nil {
65+ t.Fatal(err)
66+ }
67+ pool, buffer := c.AllocateID(), c.AllocateID()
68+ registerProtocol(t, c, pool)
69+ registerProtocol(t, c, buffer)
70+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
71+ t.Fatal(err)
72+ }
73+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
74+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
75+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
76+ if err := c.Roundtrip(); err != nil {
77+ t.Fatal(err)
78+ }
79+ w := mapped(t, events, 2*time.Second)
80+ commands <- ports.PointerFocus{ID: w.ID, X: 1, Y: 1}
81+ commands <- ports.PointerButtonTo{ID: w.ID, Button: 0x110, Pressed: true, TimeMsec: 1}
82+ var serial uint32
83+ deadline := time.After(2 * time.Second)
84+ for serial == 0 {
85+ if err := c.Roundtrip(); err != nil {
86+ t.Fatal(err)
87+ }
88+ select {
89+ case serial = <-p.serials:
90+ case <-deadline:
91+ t.Fatal("no button")
92+ default:
93+ }
94+ }
95+ // A stale serial emits nothing.
96+ requestProtocol(t, c, top, xdgshell.ToplevelRequestMove, seat, serial-1)
97+ requestProtocol(t, c, top, xdgshell.ToplevelRequestResize, seat, serial+7, uint32(xdgshell.ToplevelResizeEdgeRight))
98+ // The current press starts a move, then a resize.
99+ requestProtocol(t, c, top, xdgshell.ToplevelRequestMove, seat, serial)
100+ requestProtocol(t, c, top, xdgshell.ToplevelRequestResize, seat, serial, uint32(xdgshell.ToplevelResizeEdgeBottomLeft))
101+ if err := c.Roundtrip(); err != nil {
102+ t.Fatal(err)
103+ }
104+ var got []ports.WindowMoveRequest
105+ for len(got) < 2 {
106+ ev := waitEvent(t, events, 2*time.Second)
107+ if v, ok := ev.(ports.WindowMoveRequest); ok {
108+ got = append(got, v)
109+ }
110+ }
111+ if got[0] != (ports.WindowMoveRequest{ID: w.ID}) || got[1] != (ports.WindowMoveRequest{ID: w.ID, Resize: true, Edges: ports.ResizeBottom | ports.ResizeLeft}) {
112+ t.Fatalf("%+v", got)
113+ }
114+ select {
115+ case ev := <-events:
116+ if _, ok := ev.(ports.WindowMoveRequest); ok {
117+ t.Fatalf("extra request %+v", ev)
118+ }
119+ default:
120+ }
121+}
122+
123+func TestToplevelResizeInvalidEdge(t *testing.T) {
124+ s, _, _, dir := lifecycleServer(t)
125+ c := protocolClient(t, s, dir)
126+ seat := bindVersion(t, c, "wl_seat", 8)
127+ registerProtocol(t, c, seat)
128+ comp := bindProtocol(t, c, "wl_compositor")
129+ wm := bindProtocol(t, c, "xdg_wm_base")
130+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
131+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
132+ registerProtocol(t, c, surf)
133+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
134+ registerProtocol(t, c, xdg)
135+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
136+ registerProtocol(t, c, top)
137+ requestProtocol(t, c, top, xdgshell.ToplevelRequestResize, seat, uint32(1), uint32(3))
138+ expectProtocolError(t, c, top, uint32(xdgshell.ToplevelErrorInvalidResizeEdge))
139+}
@@ -0,0 +1,139 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
11+ "github.com/bnema/wlturbo"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// pressSerials records the serial of each pointer button event.
16+type pressSerials struct {
17+ wlturbo.BaseProxy
18+ serials chan uint32
19+}
20+
21+func (p *pressSerials) Dispatch(e *wlturbo.Event) {
22+ if e.Opcode == uint16(wayland.PointerEventButton) {
23+ p.serials <- e.Uint32()
24+ }
25+}
26+
27+func TestToplevelMoveRequest(t *testing.T) {
28+ s, events, commands, dir := lifecycleServer(t)
29+ c := protocolClient(t, s, dir)
30+ seat := bindVersion(t, c, "wl_seat", 8)
31+ registerProtocol(t, c, seat)
32+ pointer := c.AllocateID()
33+ p := &pressSerials{serials: make(chan uint32, 4)}
34+ p.SetID(pointer)
35+ registerWireProxy(c, p)
36+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
37+ comp := bindProtocol(t, c, "wl_compositor")
38+ wm := bindProtocol(t, c, "xdg_wm_base")
39+ shm := bindProtocol(t, c, "wl_shm")
40+ registerProtocol(t, c, shm)
41+ surf, xdg := c.AllocateID(), c.AllocateID()
42+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
43+ registerProtocol(t, c, surf)
44+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
45+ configured := make(chan uint32, 1)
46+ xp := &configureProxy{serial: configured}
47+ xp.SetID(xdg)
48+ registerWireProxy(c, xp)
49+ top := c.AllocateID()
50+ registerProtocol(t, c, top)
51+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
52+ // Before mapping, a move request is ignored.
53+ requestProtocol(t, c, top, xdgshell.ToplevelRequestMove, seat, uint32(0))
54+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
55+ if err := c.Roundtrip(); err != nil {
56+ t.Fatal(err)
57+ }
58+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-configured)
59+ fd, err := unix.MemfdCreate("move-buffer", 0)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ defer unix.Close(fd)
64+ if err := unix.Ftruncate(fd, 4); err != nil {
65+ t.Fatal(err)
66+ }
67+ pool, buffer := c.AllocateID(), c.AllocateID()
68+ registerProtocol(t, c, pool)
69+ registerProtocol(t, c, buffer)
70+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
71+ t.Fatal(err)
72+ }
73+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
74+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
75+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
76+ if err := c.Roundtrip(); err != nil {
77+ t.Fatal(err)
78+ }
79+ w := mapped(t, events, 2*time.Second)
80+ commands <- ports.PointerFocus{ID: w.ID, X: 1, Y: 1}
81+ commands <- ports.PointerButtonTo{ID: w.ID, Button: 0x110, Pressed: true, TimeMsec: 1}
82+ var serial uint32
83+ deadline := time.After(2 * time.Second)
84+ for serial == 0 {
85+ if err := c.Roundtrip(); err != nil {
86+ t.Fatal(err)
87+ }
88+ select {
89+ case serial = <-p.serials:
90+ case <-deadline:
91+ t.Fatal("no button")
92+ default:
93+ }
94+ }
95+ // A stale serial emits nothing.
96+ requestProtocol(t, c, top, xdgshell.ToplevelRequestMove, seat, serial-1)
97+ requestProtocol(t, c, top, xdgshell.ToplevelRequestResize, seat, serial+7, uint32(xdgshell.ToplevelResizeEdgeRight))
98+ // The current press starts a move, then a resize.
99+ requestProtocol(t, c, top, xdgshell.ToplevelRequestMove, seat, serial)
100+ requestProtocol(t, c, top, xdgshell.ToplevelRequestResize, seat, serial, uint32(xdgshell.ToplevelResizeEdgeBottomLeft))
101+ if err := c.Roundtrip(); err != nil {
102+ t.Fatal(err)
103+ }
104+ var got []ports.WindowMoveRequest
105+ for len(got) < 2 {
106+ ev := waitEvent(t, events, 2*time.Second)
107+ if v, ok := ev.(ports.WindowMoveRequest); ok {
108+ got = append(got, v)
109+ }
110+ }
111+ if got[0] != (ports.WindowMoveRequest{ID: w.ID}) || got[1] != (ports.WindowMoveRequest{ID: w.ID, Resize: true, Edges: ports.ResizeBottom | ports.ResizeLeft}) {
112+ t.Fatalf("%+v", got)
113+ }
114+ select {
115+ case ev := <-events:
116+ if _, ok := ev.(ports.WindowMoveRequest); ok {
117+ t.Fatalf("extra request %+v", ev)
118+ }
119+ default:
120+ }
121+}
122+
123+func TestToplevelResizeInvalidEdge(t *testing.T) {
124+ s, _, _, dir := lifecycleServer(t)
125+ c := protocolClient(t, s, dir)
126+ seat := bindVersion(t, c, "wl_seat", 8)
127+ registerProtocol(t, c, seat)
128+ comp := bindProtocol(t, c, "wl_compositor")
129+ wm := bindProtocol(t, c, "xdg_wm_base")
130+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
131+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
132+ registerProtocol(t, c, surf)
133+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
134+ registerProtocol(t, c, xdg)
135+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
136+ registerProtocol(t, c, top)
137+ requestProtocol(t, c, top, xdgshell.ToplevelRequestResize, seat, uint32(1), uint32(3))
138+ expectProtocolError(t, c, top, uint32(xdgshell.ToplevelErrorInvalidResizeEdge))
139+}
M internal/adapters/wayland/xdg.go +20 -2
@@ -322,8 +322,26 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
322322 }
323323 }
324324 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
325-func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {}
326-func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {}
325+
326+// Move and Resize start a pointer drag in core when they answer the
327+// client's current press; stale serials are ignored.
328+func (t top) Move(r *xdgshell.Toplevel, _ *wayland.Seat, serial uint32) {
329+ t.moveRequest(r, serial, false, 0)
330+}
331+func (t top) Resize(r *xdgshell.Toplevel, _ *wayland.Seat, serial, edges uint32) {
332+ if edges > uint32(xdgshell.ToplevelResizeEdgeBottomRight) || edges&3 == 3 || edges&12 == 12 {
333+ r.PostError(uint32(xdgshell.ToplevelErrorInvalidResizeEdge), "invalid resize edge")
334+ return
335+ }
336+ t.moveRequest(r, serial, true, ports.ResizeEdges(edges))
337+}
338+func (t top) moveRequest(r *xdgshell.Toplevel, serial uint32, resize bool, edges ports.ResizeEdges) {
339+ s := t.w.xdg.server
340+ if !t.w.mapped || serial != s.seat.press || s.seat.pressClient != r.Client() {
341+ return
342+ }
343+ s.emit(ports.WindowMoveRequest{ID: t.w.id, Resize: resize, Edges: edges})
344+}
327345 func (t top) SetMaxSize(r *xdgshell.Toplevel, w, h int32) {
328346 if w < 0 || h < 0 {
329347 r.PostError(uint32(xdgshell.ToplevelErrorInvalidSize), "negative max size")
@@ -322,8 +322,26 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
322 }322 }
323 }323 }
324 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}324 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
325-func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {}325+
326-func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {}326+// Move and Resize start a pointer drag in core when they answer the
327+// client's current press; stale serials are ignored.
328+func (t top) Move(r *xdgshell.Toplevel, _ *wayland.Seat, serial uint32) {
329+ t.moveRequest(r, serial, false, 0)
330+}
331+func (t top) Resize(r *xdgshell.Toplevel, _ *wayland.Seat, serial, edges uint32) {
332+ if edges > uint32(xdgshell.ToplevelResizeEdgeBottomRight) || edges&3 == 3 || edges&12 == 12 {
333+ r.PostError(uint32(xdgshell.ToplevelErrorInvalidResizeEdge), "invalid resize edge")
334+ return
335+ }
336+ t.moveRequest(r, serial, true, ports.ResizeEdges(edges))
337+}
338+func (t top) moveRequest(r *xdgshell.Toplevel, serial uint32, resize bool, edges ports.ResizeEdges) {
339+ s := t.w.xdg.server
340+ if !t.w.mapped || serial != s.seat.press || s.seat.pressClient != r.Client() {
341+ return
342+ }
343+ s.emit(ports.WindowMoveRequest{ID: t.w.id, Resize: resize, Edges: edges})
344+}
327 func (t top) SetMaxSize(r *xdgshell.Toplevel, w, h int32) {345 func (t top) SetMaxSize(r *xdgshell.Toplevel, w, h int32) {
328 if w < 0 || h < 0 {346 if w < 0 || h < 0 {
329 r.PostError(uint32(xdgshell.ToplevelErrorInvalidSize), "negative max size")347 r.PostError(uint32(xdgshell.ToplevelErrorInvalidSize), "negative max size")
M internal/core/core.go +41 -19
@@ -67,23 +67,32 @@ type binding struct {
6767 key string
6868 }
6969 type Core struct {
70- security ports.SecurityState
71- lockSurfaces []ports.LockSurfacePlacement
72- lockFocus WindowID
73- inputKeys map[string]bool
74- inputActive bool
75- startup [][]string
76- nextWorkspaceID uint64
77- ch Channels
78- cfg ports.Config
79- screens []*screen
80- focusScreen int
81- binds map[binding]Action
82- pressed map[string]bool
83- configures configures
84- pointer WindowID
85- grab WindowID
86- buttons map[uint32]bool
70+ security ports.SecurityState
71+ lockSurfaces []ports.LockSurfacePlacement
72+ lockFocus WindowID
73+ inputKeys map[string]bool
74+ inputActive bool
75+ startup [][]string
76+ nextWorkspaceID uint64
77+ ch Channels
78+ cfg ports.Config
79+ screens []*screen
80+ focusScreen int
81+ binds map[binding]Action
82+ pressed map[string]bool
83+ configures configures
84+ pointer WindowID
85+ grab WindowID
86+ buttons map[uint32]bool
87+ // drag is the pointer drag in progress (drag.go); swallow holds the
88+ // buttons whose release no client must see. mods are the modifiers
89+ // held, cmdMod the one keyboard.cmd names. lastButton is the latest
90+ // press a client saw, the one xdg_toplevel.move's serial names.
91+ drag *dragState
92+ swallow map[uint32]bool
93+ lastButton uint32
94+ mods ports.Mods
95+ cmdMod ports.Mods
8796 cursorX, cursorY float64 // global, logical
8897 pointerOutput string // output under the pointer at the last motion
8998 // constrained is the constraint wayland activated; constraint is it
@@ -260,6 +269,7 @@ func (c *Core) apply(cfg ports.Config) error {
260269 }
261270 c.cfg = cfg
262271 c.binds = binds
272+ c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
263273 c.specs, c.presets = named, presets
264274 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
265275 c.named()
@@ -329,7 +339,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
329339 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
330340 }
331341 // A placeholder screen holds windows until the first output arrives.
332- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, configures: newConfigures()}
342+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures()}
333343 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
334344 if err := c.apply(cfg); err != nil {
335345 return nil, err
@@ -520,6 +530,9 @@ func (c *Core) publish(ctx context.Context) error {
520530 }
521531 }
522532 scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)
533+ if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {
534+ scene.DropHints = slices.Clone(d.target.hints)
535+ }
523536 // Only the focused output lights the focused window's lines.
524537 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
525538 if sc.mon.ov.open {
@@ -621,7 +634,7 @@ func (c *Core) slid(ctx context.Context, shown bool) error {
621634 // rehit points the pointer at what now lies under a still cursor, as
622635 // windows slide under it. A held button keeps its grab.
623636 func (c *Core) rehit(ctx context.Context) error {
624- if c.grab != 0 {
637+ if c.grab != 0 || c.drag != nil {
625638 return nil
626639 }
627640 id, x, y := c.hit(c.cursorX, c.cursorY)
@@ -999,6 +1012,9 @@ func (c *Core) Run(ctx context.Context) error {
9991012 return nil
10001013 }
10011014 c.windows.drop(v.ID)
1015+ if c.drag != nil && c.drag.id == v.ID {
1016+ c.abortDrag()
1017+ }
10021018 if s, _ := c.screenOf(v.ID); s != nil {
10031019 s.mon.RemoveWindow(v.ID)
10041020 }
@@ -1015,6 +1031,10 @@ func (c *Core) Run(ctx context.Context) error {
10151031 if c.warpPointer(ctx, v) != nil {
10161032 return nil
10171033 }
1034+ case ports.WindowMoveRequest:
1035+ if c.clientDrag(ctx, v) != nil {
1036+ return nil
1037+ }
10181038 case ports.WindowFullscreenRequest:
10191039 c.configures.answer[v.ID] = true
10201040 if v.Fullscreen && !v.External && c.now().Sub(c.windows.lookup(v.ID).mappedAt) < fullscreenGrace {
@@ -1098,6 +1118,8 @@ func (c *Core) Run(ctx context.Context) error {
10981118 c.setLayers(c.allLayers())
10991119 }
11001120 case ports.OutputRemoved:
1121+ // A drop target may be on the output going away.
1122+ c.abortDrag()
11011123 c.removeScreen(v.Name)
11021124 if c.layerChanged {
11031125 c.setLayers(c.allLayers())
@@ -67,23 +67,32 @@ type binding struct {
67 key string67 key string
68 }68 }
69 type Core struct {69 type Core struct {
70- security ports.SecurityState70+ security ports.SecurityState
71- lockSurfaces []ports.LockSurfacePlacement71+ lockSurfaces []ports.LockSurfacePlacement
72- lockFocus WindowID72+ lockFocus WindowID
73- inputKeys map[string]bool73+ inputKeys map[string]bool
74- inputActive bool74+ inputActive bool
75- startup [][]string75+ startup [][]string
76- nextWorkspaceID uint6476+ nextWorkspaceID uint64
77- ch Channels77+ ch Channels
78- cfg ports.Config78+ cfg ports.Config
79- screens []*screen79+ screens []*screen
80- focusScreen int80+ focusScreen int
81- binds map[binding]Action81+ binds map[binding]Action
82- pressed map[string]bool82+ pressed map[string]bool
83- configures configures83+ configures configures
84- pointer WindowID84+ pointer WindowID
85- grab WindowID85+ grab WindowID
86- buttons map[uint32]bool86+ buttons map[uint32]bool
87+ // drag is the pointer drag in progress (drag.go); swallow holds the
88+ // buttons whose release no client must see. mods are the modifiers
89+ // held, cmdMod the one keyboard.cmd names. lastButton is the latest
90+ // press a client saw, the one xdg_toplevel.move's serial names.
91+ drag *dragState
92+ swallow map[uint32]bool
93+ lastButton uint32
94+ mods ports.Mods
95+ cmdMod ports.Mods
87 cursorX, cursorY float64 // global, logical96 cursorX, cursorY float64 // global, logical
88 pointerOutput string // output under the pointer at the last motion97 pointerOutput string // output under the pointer at the last motion
89 // constrained is the constraint wayland activated; constraint is it98 // constrained is the constraint wayland activated; constraint is it
@@ -260,6 +269,7 @@ func (c *Core) apply(cfg ports.Config) error {
260 }269 }
261 c.cfg = cfg270 c.cfg = cfg
262 c.binds = binds271 c.binds = binds
272+ c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
263 c.specs, c.presets = named, presets273 c.specs, c.presets = named, presets
264 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)274 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
265 c.named()275 c.named()
@@ -329,7 +339,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
329 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")339 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
330 }340 }
331 // A placeholder screen holds windows until the first output arrives.341 // A placeholder screen holds windows until the first output arrives.
332- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, configures: newConfigures()}342+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures()}
333 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}343 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
334 if err := c.apply(cfg); err != nil {344 if err := c.apply(cfg); err != nil {
335 return nil, err345 return nil, err
@@ -520,6 +530,9 @@ func (c *Core) publish(ctx context.Context) error {
520 }530 }
521 }531 }
522 scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)532 scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)
533+ if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {
534+ scene.DropHints = slices.Clone(d.target.hints)
535+ }
523 // Only the focused output lights the focused window's lines.536 // Only the focused output lights the focused window's lines.
524 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)537 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
525 if sc.mon.ov.open {538 if sc.mon.ov.open {
@@ -621,7 +634,7 @@ func (c *Core) slid(ctx context.Context, shown bool) error {
621 // rehit points the pointer at what now lies under a still cursor, as634 // rehit points the pointer at what now lies under a still cursor, as
622 // windows slide under it. A held button keeps its grab.635 // windows slide under it. A held button keeps its grab.
623 func (c *Core) rehit(ctx context.Context) error {636 func (c *Core) rehit(ctx context.Context) error {
624- if c.grab != 0 {637+ if c.grab != 0 || c.drag != nil {
625 return nil638 return nil
626 }639 }
627 id, x, y := c.hit(c.cursorX, c.cursorY)640 id, x, y := c.hit(c.cursorX, c.cursorY)
@@ -999,6 +1012,9 @@ func (c *Core) Run(ctx context.Context) error {
999 return nil1012 return nil
1000 }1013 }
1001 c.windows.drop(v.ID)1014 c.windows.drop(v.ID)
1015+ if c.drag != nil && c.drag.id == v.ID {
1016+ c.abortDrag()
1017+ }
1002 if s, _ := c.screenOf(v.ID); s != nil {1018 if s, _ := c.screenOf(v.ID); s != nil {
1003 s.mon.RemoveWindow(v.ID)1019 s.mon.RemoveWindow(v.ID)
1004 }1020 }
@@ -1015,6 +1031,10 @@ func (c *Core) Run(ctx context.Context) error {
1015 if c.warpPointer(ctx, v) != nil {1031 if c.warpPointer(ctx, v) != nil {
1016 return nil1032 return nil
1017 }1033 }
1034+ case ports.WindowMoveRequest:
1035+ if c.clientDrag(ctx, v) != nil {
1036+ return nil
1037+ }
1018 case ports.WindowFullscreenRequest:1038 case ports.WindowFullscreenRequest:
1019 c.configures.answer[v.ID] = true1039 c.configures.answer[v.ID] = true
1020 if v.Fullscreen && !v.External && c.now().Sub(c.windows.lookup(v.ID).mappedAt) < fullscreenGrace {1040 if v.Fullscreen && !v.External && c.now().Sub(c.windows.lookup(v.ID).mappedAt) < fullscreenGrace {
@@ -1098,6 +1118,8 @@ func (c *Core) Run(ctx context.Context) error {
1098 c.setLayers(c.allLayers())1118 c.setLayers(c.allLayers())
1099 }1119 }
1100 case ports.OutputRemoved:1120 case ports.OutputRemoved:
1121+ // A drop target may be on the output going away.
1122+ c.abortDrag()
1101 c.removeScreen(v.Name)1123 c.removeScreen(v.Name)
1102 if c.layerChanged {1124 if c.layerChanged {
1103 c.setLayers(c.allLayers())1125 c.setLayers(c.allLayers())
A internal/core/drag.go +585 -0
@@ -0,0 +1,585 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Pointer drag: cmd + left button moves the window under the pointer,
11+// cmd + right button resizes a float; xdg_toplevel.move/resize start the
12+// same drag with the held button. A float follows the pointer. A tile
13+// stays in place until the button is released, and NeferWL draws where
14+// it lands (Scene.DropHints):
15+//
16+// - top or bottom quarter of a window: stack above or below it;
17+// - middle of a window: swap the columns (a stacked window becomes a
18+// new column there);
19+// - scroll overflow, the gap between two columns: a new column there.
20+//
21+// The client gets no pointer events during the drag; Escape cancels it.
22+
23+const (
24+ btnLeft = 0x110
25+ btnRight = 0x111
26+ // dropBand is the share of a window's height, top and bottom, that
27+ // stacks above or below it.
28+ dropBandDiv = 4
29+ // gapPad is the least half-width of a gap's insert zone, logical px.
30+ gapPad = 12
31+ // edgeScroll is the distance from the usable edge, logical px, that
32+ // scrolls a scroll-overflow workspace during a tile drag, at most
33+ // once per edgeScrollMsec.
34+ edgeScroll = 32
35+ edgeScrollMsec = 250
36+)
37+
38+type dropKind int
39+
40+const (
41+ dropNone dropKind = iota
42+ dropAbove
43+ dropBelow
44+ dropSwap
45+ dropInsert
46+)
47+
48+// dropTarget is where a dragged tile lands. anchor is the hovered window
49+// (above, below, swap) or a window of the column the new one goes before
50+// (insert; 0 is the end).
51+type dropTarget struct {
52+ kind dropKind
53+ screen *screen
54+ ws *Workspace
55+ anchor WindowID
56+ hints []Rect
57+}
58+
59+type dragState struct {
60+ id WindowID
61+ button uint32
62+ resize bool
63+ edges ports.ResizeEdges
64+ // start is the cursor at the press, global; from is the float as it
65+ // was, restored by Escape; rect is its place then.
66+ start [2]float64
67+ from Float
68+ rect Rect
69+ float bool
70+ target dropTarget
71+ // scrolled is the time of the last edge scroll.
72+ scrolled uint32
73+}
74+
75+// draggable returns the screen and workspace of a window a drag may
76+// take: a tile or a native or free float of the workspace on screen,
77+// not fullscreen, outside the overview.
78+func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
79+ s, w := c.screenOf(id)
80+ if s == nil || w != s.mon.Current() || s.mon.ov.open || id == w.fullscreen || w.cover() != 0 {
81+ return nil, nil, false
82+ }
83+ if w.floatIndex(id) < 0 && w.columnOf(id) < 0 {
84+ return nil, nil, false
85+ }
86+ return s, w, true
87+}
88+
89+// startDrag starts a cmd + button drag on the window under the pointer.
90+// It reports whether it did: the press is then not forwarded.
91+func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {
92+ if !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
93+ return false, nil
94+ }
95+ if v.Button != btnLeft && v.Button != btnRight {
96+ return false, nil
97+ }
98+ id := c.pointer
99+ if id == 0 || c.keyboard.inhibited(c.keyboardFocus()) {
100+ return false, nil
101+ }
102+ _, w, ok := c.draggable(id)
103+ // Tiles only move; resizing them with the pointer is not supported.
104+ if !ok || v.Button == btnRight && w.floatIndex(id) < 0 {
105+ return false, nil
106+ }
107+ var edges ports.ResizeEdges
108+ if v.Button == btnRight {
109+ edges = ports.ResizeRight | ports.ResizeBottom
110+ }
111+ return true, c.beginDrag(ctx, id, v.Button, v.Button == btnRight, edges)
112+}
113+
114+// held is the number of held buttons a client saw pressed: swallowed
115+// ones stay in c.buttons only for the security gate.
116+func (c *Core) held() int {
117+ n := 0
118+ for b := range c.buttons {
119+ if !c.swallow[b] {
120+ n++
121+ }
122+ }
123+ return n
124+}
125+
126+// clientDrag starts a drag for xdg_toplevel.move/resize: the window must
127+// hold the pointer grab of a held button.
128+func (c *Core) clientDrag(ctx context.Context, v ports.WindowMoveRequest) error {
129+ // The adapter checked the serial of the last press: that button drags.
130+ button := c.lastButton
131+ if c.drag != nil || c.grab != v.ID || !c.buttons[button] || c.swallow[button] {
132+ return nil
133+ }
134+ _, w, ok := c.draggable(v.ID)
135+ if !ok || v.Resize && (w.floatIndex(v.ID) < 0 || v.Edges == 0) {
136+ return nil
137+ }
138+ // Other held buttons release without a client to tell.
139+ for b := range c.buttons {
140+ if b != button {
141+ c.swallow[b] = true
142+ }
143+ }
144+ return c.beginDrag(ctx, v.ID, button, v.Resize, v.Edges)
145+}
146+
147+func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize bool, edges ports.ResizeEdges) error {
148+ if err := c.dismissGrabs(ctx, id); err != nil {
149+ return err
150+ }
151+ s, w, ok := c.draggable(id)
152+ if !ok {
153+ return nil
154+ }
155+ // Click raises a float above the columns: Escape keeps that.
156+ w.Click(id)
157+ d := &dragState{id: id, button: button, resize: resize, edges: edges, start: [2]float64{c.cursorX, c.cursorY}}
158+ if i := w.floatIndex(id); i >= 0 {
159+ d.float, d.from, d.rect = true, w.Floats[i], w.floatRect(w.Floats[i])
160+ }
161+ c.drag = d
162+ c.buttons[button] = true
163+ c.focusScreen = c.screenIndex(s.name())
164+ c.grab = 0
165+ if c.pointer != 0 {
166+ c.pointer = 0
167+ if err := c.command(ctx, ports.PointerFocus{}); err != nil {
168+ return err
169+ }
170+ }
171+ return c.publish(ctx)
172+}
173+
174+// dragMotion follows the cursor: a float moves or resizes, a tile shows
175+// where it would land.
176+func (c *Core) dragMotion(ctx context.Context, timeMsec uint32) error {
177+ d := c.drag
178+ _, w, ok := c.draggable(d.id)
179+ if !ok {
180+ c.abortDrag()
181+ return c.publish(ctx)
182+ }
183+ dx, dy := int(c.cursorX-d.start[0]), int(c.cursorY-d.start[1])
184+ if d.float {
185+ i := w.floatIndex(d.id)
186+ if i < 0 {
187+ c.abortDrag()
188+ return c.publish(ctx)
189+ }
190+ f := &w.Floats[i]
191+ w.makeFree(f)
192+ if d.resize {
193+ r := resized(d.rect, d.edges, dx, dy, 2*w.border+freeMinSize, w.Usable)
194+ f.W, f.H = r.W-2*w.border, r.H-2*w.border
195+ w.moveFree(f, r, 0, 0)
196+ } else {
197+ w.moveFree(f, d.rect, dx, dy)
198+ }
199+ return c.publish(ctx)
200+ }
201+ scrolled := c.edgeScroll(timeMsec)
202+ t := c.dropAt(d.id, c.cursorX, c.cursorY)
203+ if !scrolled && t.kind == d.target.kind && t.anchor == d.target.anchor && t.ws == d.target.ws && slices.Equal(t.hints, d.target.hints) {
204+ return nil
205+ }
206+ d.target = t
207+ return c.publish(ctx)
208+}
209+
210+// resized is r with the edges moved by dx, dy, at least min wide and
211+// high, inside area u.
212+func resized(r Rect, edges ports.ResizeEdges, dx, dy, least int, u Rect) Rect {
213+ left, top, right, bottom := r.X, r.Y, r.X+r.W, r.Y+r.H
214+ if edges&ports.ResizeLeft != 0 {
215+ left = min(max(left+dx, u.X), right-least)
216+ }
217+ if edges&ports.ResizeRight != 0 {
218+ right = max(min(right+dx, u.X+u.W), left+least)
219+ }
220+ if edges&ports.ResizeTop != 0 {
221+ top = min(max(top+dy, u.Y), bottom-least)
222+ }
223+ if edges&ports.ResizeBottom != 0 {
224+ bottom = max(min(bottom+dy, u.Y+u.H), top+least)
225+ }
226+ return Rect{X: left, Y: top, W: right - left, H: bottom - top}
227+}
228+
229+// endDrag drops the window on release: a tile lands on its target.
230+func (c *Core) endDrag(ctx context.Context) error {
231+ d := c.drag
232+ c.drag = nil
233+ if !d.float {
234+ if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone {
235+ c.drop(d.id, t)
236+ // Slots and guests follow the windows, as after a bind.
237+ if err := c.workspaceVisible(ctx, false); err != nil {
238+ return err
239+ }
240+ }
241+ }
242+ if err := c.publish(ctx); err != nil {
243+ return err
244+ }
245+ return c.rehit(ctx)
246+}
247+
248+// cancelDrag puts a dragged float back; a tile never moved.
249+func (c *Core) cancelDrag() {
250+ d := c.drag
251+ if d.float {
252+ if _, w := c.screenOf(d.id); w != nil {
253+ if i := w.floatIndex(d.id); i >= 0 {
254+ w.Floats[i] = d.from
255+ }
256+ }
257+ }
258+ c.abortDrag()
259+}
260+
261+// abortDrag ends the drag where it is; the held button's release is
262+// dropped.
263+func (c *Core) abortDrag() {
264+ if c.drag != nil {
265+ c.swallow[c.drag.button] = true
266+ c.drag = nil
267+ }
268+}
269+
270+// dropAt is the drop target under the global point x, y.
271+func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {
272+ o, ok := c.layout().At(x, y)
273+ if !ok {
274+ return dropTarget{}
275+ }
276+ sc := c.screens[c.screenIndex(o.Info.Name)]
277+ m := sc.mon
278+ lx, ly := int(x)-o.X, int(y)-o.Y
279+ w := m.Current()
280+ if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {
281+ return dropTarget{}
282+ }
283+ t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4))
284+ t.screen, t.ws = sc, w
285+ return t
286+}
287+
288+// visibleColumn is a column on screen and the bounds of its windows.
289+type visibleColumn struct {
290+ index int
291+ rect Rect
292+}
293+
294+// dropIn resolves the drop of window id at the output-local point x, y,
295+// hints t logical px thick.
296+func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget {
297+ layout := w.Layout()
298+ for _, p := range slices.Backward(layout) {
299+ // Floats and the stash are over the tiles: nothing lands there.
300+ if p.Floating && !p.Hidden && !p.Below && p.ID != id && contains(p.Rect, x, y) {
301+ return dropTarget{}
302+ }
303+ }
304+ src, srcRow := w.columnOf(id), -1
305+ if src >= 0 {
306+ srcRow = slices.Index(w.Columns[src].Windows, id)
307+ }
308+ lone := src >= 0 && len(w.Columns[src].Windows) == 1
309+ var cols []visibleColumn
310+ for _, p := range layout {
311+ if p.Floating || p.Hidden || !p.Rect.Overlaps(w.Output) {
312+ continue
313+ }
314+ ci := w.columnOf(p.ID)
315+ if ci < 0 {
316+ continue
317+ }
318+ if n := len(cols); n > 0 && cols[n-1].index == ci {
319+ cols[n-1].rect = union(cols[n-1].rect, p.Rect)
320+ } else {
321+ cols = append(cols, visibleColumn{index: ci, rect: p.Rect})
322+ }
323+ }
324+ if len(w.Columns) == 0 || src < 0 && len(cols) == 0 {
325+ if !contains(w.Usable, x, y) {
326+ return dropTarget{}
327+ }
328+ g := w.gap()
329+ r := Rect{X: w.Usable.X + g, Y: w.Usable.Y + g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
330+ return dropTarget{kind: dropInsert, hints: outline(r, t)}
331+ }
332+ if w.Overflow != OverflowFixed {
333+ if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok {
334+ return tgt
335+ }
336+ }
337+ for _, p := range layout {
338+ if p.Floating || p.Hidden || !contains(p.Rect, x, y) {
339+ continue
340+ }
341+ ci := w.columnOf(p.ID)
342+ if ci < 0 {
343+ continue
344+ }
345+ r := p.Rect
346+ band := max(r.H/dropBandDiv, 1)
347+ row := slices.Index(w.Columns[ci].Windows, p.ID)
348+ switch {
349+ case y < r.Y+band:
350+ // Above the window just below it, or above itself: no move.
351+ if p.ID == id || ci == src && row == srcRow+1 {
352+ return dropTarget{}
353+ }
354+ return dropTarget{kind: dropAbove, anchor: p.ID, hints: []Rect{{X: r.X, Y: r.Y, W: r.W, H: min(t, r.H)}}}
355+ case y >= r.Y+r.H-band:
356+ if p.ID == id || ci == src && row == srcRow-1 {
357+ return dropTarget{}
358+ }
359+ return dropTarget{kind: dropBelow, anchor: p.ID, hints: []Rect{{X: r.X, Y: r.Y + r.H - min(t, r.H), W: r.W, H: min(t, r.H)}}}
360+ default:
361+ if ci == src {
362+ return dropTarget{}
363+ }
364+ for _, vc := range cols {
365+ if vc.index == ci {
366+ return dropTarget{kind: dropSwap, anchor: p.ID, hints: outline(vc.rect, t)}
367+ }
368+ }
369+ return dropTarget{}
370+ }
371+ }
372+ return dropTarget{}
373+}
374+
375+// gapDrop is the insert zone of scroll overflow: the gaps between the
376+// columns on screen and beside the outer ones.
377+func (w *Workspace) gapDrop(cols []visibleColumn, id WindowID, src int, lone bool, x, y, t int) (dropTarget, bool) {
378+ pad := max(w.gap()/2, gapPad)
379+ for i := 0; i <= len(cols); i++ {
380+ var mid, at int
381+ var ref Rect
382+ switch {
383+ case i == 0:
384+ ref = cols[0].rect
385+ mid, at = ref.X-w.gap()/2, cols[0].index
386+ case i == len(cols):
387+ ref = cols[i-1].rect
388+ mid, at = ref.X+ref.W+w.gap()/2, cols[i-1].index+1
389+ default:
390+ a, b := cols[i-1].rect, cols[i].rect
391+ ref = union(a, b)
392+ mid, at = (a.X+a.W+b.X)/2, cols[i].index
393+ }
394+ if x < mid-pad || x >= mid+pad || y < ref.Y || y >= ref.Y+ref.H {
395+ continue
396+ }
397+ // A lone column next to its own place does not move.
398+ if lone && (at == src || at == src+1) {
399+ return dropTarget{}, true
400+ }
401+ var anchor WindowID
402+ if at < len(w.Columns) {
403+ anchor = w.Columns[at].Windows[0]
404+ if anchor == id {
405+ anchor = w.Columns[at].Windows[1]
406+ }
407+ }
408+ hint := Rect{X: mid - t/2, Y: ref.Y, W: t, H: ref.H}
409+ return dropTarget{kind: dropInsert, anchor: anchor, hints: []Rect{hint}}, true
410+ }
411+ return dropTarget{}, false
412+}
413+
414+// contains reports whether r holds the point x, y.
415+func contains(r Rect, x, y int) bool {
416+ return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H
417+}
418+
419+// union is the smallest rect holding a and b.
420+func union(a, b Rect) Rect {
421+ x, y := min(a.X, b.X), min(a.Y, b.Y)
422+ return Rect{X: x, Y: y, W: max(a.X+a.W, b.X+b.W) - x, H: max(a.Y+a.H, b.Y+b.H) - y}
423+}
424+
425+// outline is the four sides of r, t thick, inside it.
426+func outline(r Rect, t int) []Rect {
427+ t = min(t, r.W/2, r.H/2)
428+ if t <= 0 {
429+ return nil
430+ }
431+ return []Rect{
432+ {X: r.X, Y: r.Y, W: r.W, H: t},
433+ {X: r.X, Y: r.Y + r.H - t, W: r.W, H: t},
434+ {X: r.X, Y: r.Y + t, W: t, H: r.H - 2*t},
435+ {X: r.X + r.W - t, Y: r.Y + t, W: t, H: r.H - 2*t},
436+ }
437+}
438+
439+// drop moves tile id to target t, possibly on another workspace or
440+// screen; focus follows it.
441+func (c *Core) drop(id WindowID, t dropTarget) {
442+ from, src := c.screenOf(id)
443+ if from == nil || t.ws == nil || src.columnOf(id) < 0 {
444+ return
445+ }
446+ w := t.ws
447+ ci := src.columnOf(id)
448+ lone := len(src.Columns[ci].Windows) == 1
449+ if t.kind == dropSwap && w == src && lone {
450+ to := w.columnOf(t.anchor)
451+ // Their fields move with them, as MoveColumn.
452+ w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
453+ w.FocusID(id)
454+ } else {
455+ width := Width{}
456+ if lone {
457+ width = src.Columns[ci].Width
458+ }
459+ srcIndex := ci
460+ src.dropSlotOf(id)
461+ src.RemoveWindow(id)
462+ col := Column{Windows: []WindowID{id}, Width: width}
463+ switch t.kind {
464+ case dropAbove, dropBelow:
465+ ai := w.columnOf(t.anchor)
466+ if ai < 0 {
467+ w.receive(col, -1)
468+ break
469+ }
470+ tc := &w.Columns[ai]
471+ row := slices.Index(tc.Windows, t.anchor)
472+ if t.kind == dropBelow {
473+ row++
474+ }
475+ if row == 0 {
476+ // The slot follows the first window.
477+ tc.Slot = 0
478+ }
479+ tc.Windows = slices.Insert(tc.Windows, row, id)
480+ tc.Shares = nil
481+ w.Activate(id)
482+ default:
483+ at, anchorCol := len(w.Columns), -1
484+ if t.anchor != 0 {
485+ if ai := w.columnOf(t.anchor); ai >= 0 {
486+ at, anchorCol = ai, ai
487+ // A stacked window dropped on a column right of its
488+ // own lands after it.
489+ if t.kind == dropSwap && w == src && ai >= srcIndex {
490+ at = ai + 1
491+ }
492+ }
493+ }
494+ if w.full() {
495+ // No room for a column: it joins the outlined one.
496+ i := min(at, len(w.Columns)-1)
497+ if anchorCol >= 0 {
498+ i = anchorCol
499+ }
500+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
501+ w.Columns[i].Shares = nil
502+ w.Activate(id)
503+ } else {
504+ w.receive(col, at)
505+ }
506+ }
507+ }
508+ from.mon.normalize()
509+ t.screen.mon.normalize()
510+ c.focusScreen = c.screenIndex(t.screen.name())
511+}
512+
513+// edgeScroll scrolls a scroll-overflow workspace by one column when a
514+// tile drag moves the pointer against its usable edge. It reports whether
515+// the view moved.
516+func (c *Core) edgeScroll(timeMsec uint32) bool {
517+ o, ok := c.layout().At(c.cursorX, c.cursorY)
518+ if !ok || timeMsec-c.drag.scrolled < edgeScrollMsec && c.drag.scrolled != 0 {
519+ return false
520+ }
521+ w := c.screens[c.screenIndex(o.Info.Name)].mon.Current()
522+ if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() {
523+ return false
524+ }
525+ lx := int(c.cursorX) - o.X
526+ dir := 0
527+ switch {
528+ case lx < w.Usable.X+edgeScroll:
529+ dir = -1
530+ case lx >= w.Usable.X+w.Usable.W-edgeScroll:
531+ dir = 1
532+ default:
533+ return false
534+ }
535+ if !w.scrollBy(dir) {
536+ return false
537+ }
538+ c.drag.scrolled = max(timeMsec, 1)
539+ return true
540+}
541+
542+// scrollBy moves the view one column toward dir, within the row; the
543+// focus stays. It reports whether the view moved.
544+func (w *Workspace) scrollBy(dir int) bool {
545+ g := w.gap()
546+ n := len(w.Columns)
547+ maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0)
548+ view := w.ViewX
549+ if dir < 0 {
550+ // The first column not fully on screen at the left.
551+ for i := n - 1; i >= 0; i-- {
552+ if w.columnX(i)-view < w.Usable.X+g {
553+ view = w.columnX(i) - w.Usable.X - g
554+ break
555+ }
556+ }
557+ } else {
558+ for i := range n {
559+ if w.columnX(i)+w.columnWidth(i)-view > w.Usable.X+w.Usable.W-g {
560+ view = w.columnX(i) + w.columnWidth(i) + g - w.Usable.X - w.Usable.W
561+ break
562+ }
563+ }
564+ }
565+ view = min(max(view, 0), maxView)
566+ if view == w.ViewX {
567+ return false
568+ }
569+ w.stopSlide()
570+ w.ViewX = view
571+ return true
572+}
573+
574+// makeFree turns native float f into a free float where it is now.
575+func (w *Workspace) makeFree(f *Float) {
576+ if f.free {
577+ return
578+ }
579+ r := w.floatRect(*f)
580+ if f.W <= 0 || f.H <= 0 {
581+ f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
582+ }
583+ f.free = true
584+ w.moveFree(f, r, 0, 0)
585+}
@@ -0,0 +1,585 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Pointer drag: cmd + left button moves the window under the pointer,
11+// cmd + right button resizes a float; xdg_toplevel.move/resize start the
12+// same drag with the held button. A float follows the pointer. A tile
13+// stays in place until the button is released, and NeferWL draws where
14+// it lands (Scene.DropHints):
15+//
16+// - top or bottom quarter of a window: stack above or below it;
17+// - middle of a window: swap the columns (a stacked window becomes a
18+// new column there);
19+// - scroll overflow, the gap between two columns: a new column there.
20+//
21+// The client gets no pointer events during the drag; Escape cancels it.
22+
23+const (
24+ btnLeft = 0x110
25+ btnRight = 0x111
26+ // dropBand is the share of a window's height, top and bottom, that
27+ // stacks above or below it.
28+ dropBandDiv = 4
29+ // gapPad is the least half-width of a gap's insert zone, logical px.
30+ gapPad = 12
31+ // edgeScroll is the distance from the usable edge, logical px, that
32+ // scrolls a scroll-overflow workspace during a tile drag, at most
33+ // once per edgeScrollMsec.
34+ edgeScroll = 32
35+ edgeScrollMsec = 250
36+)
37+
38+type dropKind int
39+
40+const (
41+ dropNone dropKind = iota
42+ dropAbove
43+ dropBelow
44+ dropSwap
45+ dropInsert
46+)
47+
48+// dropTarget is where a dragged tile lands. anchor is the hovered window
49+// (above, below, swap) or a window of the column the new one goes before
50+// (insert; 0 is the end).
51+type dropTarget struct {
52+ kind dropKind
53+ screen *screen
54+ ws *Workspace
55+ anchor WindowID
56+ hints []Rect
57+}
58+
59+type dragState struct {
60+ id WindowID
61+ button uint32
62+ resize bool
63+ edges ports.ResizeEdges
64+ // start is the cursor at the press, global; from is the float as it
65+ // was, restored by Escape; rect is its place then.
66+ start [2]float64
67+ from Float
68+ rect Rect
69+ float bool
70+ target dropTarget
71+ // scrolled is the time of the last edge scroll.
72+ scrolled uint32
73+}
74+
75+// draggable returns the screen and workspace of a window a drag may
76+// take: a tile or a native or free float of the workspace on screen,
77+// not fullscreen, outside the overview.
78+func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
79+ s, w := c.screenOf(id)
80+ if s == nil || w != s.mon.Current() || s.mon.ov.open || id == w.fullscreen || w.cover() != 0 {
81+ return nil, nil, false
82+ }
83+ if w.floatIndex(id) < 0 && w.columnOf(id) < 0 {
84+ return nil, nil, false
85+ }
86+ return s, w, true
87+}
88+
89+// startDrag starts a cmd + button drag on the window under the pointer.
90+// It reports whether it did: the press is then not forwarded.
91+func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {
92+ if !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
93+ return false, nil
94+ }
95+ if v.Button != btnLeft && v.Button != btnRight {
96+ return false, nil
97+ }
98+ id := c.pointer
99+ if id == 0 || c.keyboard.inhibited(c.keyboardFocus()) {
100+ return false, nil
101+ }
102+ _, w, ok := c.draggable(id)
103+ // Tiles only move; resizing them with the pointer is not supported.
104+ if !ok || v.Button == btnRight && w.floatIndex(id) < 0 {
105+ return false, nil
106+ }
107+ var edges ports.ResizeEdges
108+ if v.Button == btnRight {
109+ edges = ports.ResizeRight | ports.ResizeBottom
110+ }
111+ return true, c.beginDrag(ctx, id, v.Button, v.Button == btnRight, edges)
112+}
113+
114+// held is the number of held buttons a client saw pressed: swallowed
115+// ones stay in c.buttons only for the security gate.
116+func (c *Core) held() int {
117+ n := 0
118+ for b := range c.buttons {
119+ if !c.swallow[b] {
120+ n++
121+ }
122+ }
123+ return n
124+}
125+
126+// clientDrag starts a drag for xdg_toplevel.move/resize: the window must
127+// hold the pointer grab of a held button.
128+func (c *Core) clientDrag(ctx context.Context, v ports.WindowMoveRequest) error {
129+ // The adapter checked the serial of the last press: that button drags.
130+ button := c.lastButton
131+ if c.drag != nil || c.grab != v.ID || !c.buttons[button] || c.swallow[button] {
132+ return nil
133+ }
134+ _, w, ok := c.draggable(v.ID)
135+ if !ok || v.Resize && (w.floatIndex(v.ID) < 0 || v.Edges == 0) {
136+ return nil
137+ }
138+ // Other held buttons release without a client to tell.
139+ for b := range c.buttons {
140+ if b != button {
141+ c.swallow[b] = true
142+ }
143+ }
144+ return c.beginDrag(ctx, v.ID, button, v.Resize, v.Edges)
145+}
146+
147+func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize bool, edges ports.ResizeEdges) error {
148+ if err := c.dismissGrabs(ctx, id); err != nil {
149+ return err
150+ }
151+ s, w, ok := c.draggable(id)
152+ if !ok {
153+ return nil
154+ }
155+ // Click raises a float above the columns: Escape keeps that.
156+ w.Click(id)
157+ d := &dragState{id: id, button: button, resize: resize, edges: edges, start: [2]float64{c.cursorX, c.cursorY}}
158+ if i := w.floatIndex(id); i >= 0 {
159+ d.float, d.from, d.rect = true, w.Floats[i], w.floatRect(w.Floats[i])
160+ }
161+ c.drag = d
162+ c.buttons[button] = true
163+ c.focusScreen = c.screenIndex(s.name())
164+ c.grab = 0
165+ if c.pointer != 0 {
166+ c.pointer = 0
167+ if err := c.command(ctx, ports.PointerFocus{}); err != nil {
168+ return err
169+ }
170+ }
171+ return c.publish(ctx)
172+}
173+
174+// dragMotion follows the cursor: a float moves or resizes, a tile shows
175+// where it would land.
176+func (c *Core) dragMotion(ctx context.Context, timeMsec uint32) error {
177+ d := c.drag
178+ _, w, ok := c.draggable(d.id)
179+ if !ok {
180+ c.abortDrag()
181+ return c.publish(ctx)
182+ }
183+ dx, dy := int(c.cursorX-d.start[0]), int(c.cursorY-d.start[1])
184+ if d.float {
185+ i := w.floatIndex(d.id)
186+ if i < 0 {
187+ c.abortDrag()
188+ return c.publish(ctx)
189+ }
190+ f := &w.Floats[i]
191+ w.makeFree(f)
192+ if d.resize {
193+ r := resized(d.rect, d.edges, dx, dy, 2*w.border+freeMinSize, w.Usable)
194+ f.W, f.H = r.W-2*w.border, r.H-2*w.border
195+ w.moveFree(f, r, 0, 0)
196+ } else {
197+ w.moveFree(f, d.rect, dx, dy)
198+ }
199+ return c.publish(ctx)
200+ }
201+ scrolled := c.edgeScroll(timeMsec)
202+ t := c.dropAt(d.id, c.cursorX, c.cursorY)
203+ if !scrolled && t.kind == d.target.kind && t.anchor == d.target.anchor && t.ws == d.target.ws && slices.Equal(t.hints, d.target.hints) {
204+ return nil
205+ }
206+ d.target = t
207+ return c.publish(ctx)
208+}
209+
210+// resized is r with the edges moved by dx, dy, at least min wide and
211+// high, inside area u.
212+func resized(r Rect, edges ports.ResizeEdges, dx, dy, least int, u Rect) Rect {
213+ left, top, right, bottom := r.X, r.Y, r.X+r.W, r.Y+r.H
214+ if edges&ports.ResizeLeft != 0 {
215+ left = min(max(left+dx, u.X), right-least)
216+ }
217+ if edges&ports.ResizeRight != 0 {
218+ right = max(min(right+dx, u.X+u.W), left+least)
219+ }
220+ if edges&ports.ResizeTop != 0 {
221+ top = min(max(top+dy, u.Y), bottom-least)
222+ }
223+ if edges&ports.ResizeBottom != 0 {
224+ bottom = max(min(bottom+dy, u.Y+u.H), top+least)
225+ }
226+ return Rect{X: left, Y: top, W: right - left, H: bottom - top}
227+}
228+
229+// endDrag drops the window on release: a tile lands on its target.
230+func (c *Core) endDrag(ctx context.Context) error {
231+ d := c.drag
232+ c.drag = nil
233+ if !d.float {
234+ if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone {
235+ c.drop(d.id, t)
236+ // Slots and guests follow the windows, as after a bind.
237+ if err := c.workspaceVisible(ctx, false); err != nil {
238+ return err
239+ }
240+ }
241+ }
242+ if err := c.publish(ctx); err != nil {
243+ return err
244+ }
245+ return c.rehit(ctx)
246+}
247+
248+// cancelDrag puts a dragged float back; a tile never moved.
249+func (c *Core) cancelDrag() {
250+ d := c.drag
251+ if d.float {
252+ if _, w := c.screenOf(d.id); w != nil {
253+ if i := w.floatIndex(d.id); i >= 0 {
254+ w.Floats[i] = d.from
255+ }
256+ }
257+ }
258+ c.abortDrag()
259+}
260+
261+// abortDrag ends the drag where it is; the held button's release is
262+// dropped.
263+func (c *Core) abortDrag() {
264+ if c.drag != nil {
265+ c.swallow[c.drag.button] = true
266+ c.drag = nil
267+ }
268+}
269+
270+// dropAt is the drop target under the global point x, y.
271+func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {
272+ o, ok := c.layout().At(x, y)
273+ if !ok {
274+ return dropTarget{}
275+ }
276+ sc := c.screens[c.screenIndex(o.Info.Name)]
277+ m := sc.mon
278+ lx, ly := int(x)-o.X, int(y)-o.Y
279+ w := m.Current()
280+ if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {
281+ return dropTarget{}
282+ }
283+ t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4))
284+ t.screen, t.ws = sc, w
285+ return t
286+}
287+
288+// visibleColumn is a column on screen and the bounds of its windows.
289+type visibleColumn struct {
290+ index int
291+ rect Rect
292+}
293+
294+// dropIn resolves the drop of window id at the output-local point x, y,
295+// hints t logical px thick.
296+func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget {
297+ layout := w.Layout()
298+ for _, p := range slices.Backward(layout) {
299+ // Floats and the stash are over the tiles: nothing lands there.
300+ if p.Floating && !p.Hidden && !p.Below && p.ID != id && contains(p.Rect, x, y) {
301+ return dropTarget{}
302+ }
303+ }
304+ src, srcRow := w.columnOf(id), -1
305+ if src >= 0 {
306+ srcRow = slices.Index(w.Columns[src].Windows, id)
307+ }
308+ lone := src >= 0 && len(w.Columns[src].Windows) == 1
309+ var cols []visibleColumn
310+ for _, p := range layout {
311+ if p.Floating || p.Hidden || !p.Rect.Overlaps(w.Output) {
312+ continue
313+ }
314+ ci := w.columnOf(p.ID)
315+ if ci < 0 {
316+ continue
317+ }
318+ if n := len(cols); n > 0 && cols[n-1].index == ci {
319+ cols[n-1].rect = union(cols[n-1].rect, p.Rect)
320+ } else {
321+ cols = append(cols, visibleColumn{index: ci, rect: p.Rect})
322+ }
323+ }
324+ if len(w.Columns) == 0 || src < 0 && len(cols) == 0 {
325+ if !contains(w.Usable, x, y) {
326+ return dropTarget{}
327+ }
328+ g := w.gap()
329+ r := Rect{X: w.Usable.X + g, Y: w.Usable.Y + g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
330+ return dropTarget{kind: dropInsert, hints: outline(r, t)}
331+ }
332+ if w.Overflow != OverflowFixed {
333+ if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok {
334+ return tgt
335+ }
336+ }
337+ for _, p := range layout {
338+ if p.Floating || p.Hidden || !contains(p.Rect, x, y) {
339+ continue
340+ }
341+ ci := w.columnOf(p.ID)
342+ if ci < 0 {
343+ continue
344+ }
345+ r := p.Rect
346+ band := max(r.H/dropBandDiv, 1)
347+ row := slices.Index(w.Columns[ci].Windows, p.ID)
348+ switch {
349+ case y < r.Y+band:
350+ // Above the window just below it, or above itself: no move.
351+ if p.ID == id || ci == src && row == srcRow+1 {
352+ return dropTarget{}
353+ }
354+ return dropTarget{kind: dropAbove, anchor: p.ID, hints: []Rect{{X: r.X, Y: r.Y, W: r.W, H: min(t, r.H)}}}
355+ case y >= r.Y+r.H-band:
356+ if p.ID == id || ci == src && row == srcRow-1 {
357+ return dropTarget{}
358+ }
359+ return dropTarget{kind: dropBelow, anchor: p.ID, hints: []Rect{{X: r.X, Y: r.Y + r.H - min(t, r.H), W: r.W, H: min(t, r.H)}}}
360+ default:
361+ if ci == src {
362+ return dropTarget{}
363+ }
364+ for _, vc := range cols {
365+ if vc.index == ci {
366+ return dropTarget{kind: dropSwap, anchor: p.ID, hints: outline(vc.rect, t)}
367+ }
368+ }
369+ return dropTarget{}
370+ }
371+ }
372+ return dropTarget{}
373+}
374+
375+// gapDrop is the insert zone of scroll overflow: the gaps between the
376+// columns on screen and beside the outer ones.
377+func (w *Workspace) gapDrop(cols []visibleColumn, id WindowID, src int, lone bool, x, y, t int) (dropTarget, bool) {
378+ pad := max(w.gap()/2, gapPad)
379+ for i := 0; i <= len(cols); i++ {
380+ var mid, at int
381+ var ref Rect
382+ switch {
383+ case i == 0:
384+ ref = cols[0].rect
385+ mid, at = ref.X-w.gap()/2, cols[0].index
386+ case i == len(cols):
387+ ref = cols[i-1].rect
388+ mid, at = ref.X+ref.W+w.gap()/2, cols[i-1].index+1
389+ default:
390+ a, b := cols[i-1].rect, cols[i].rect
391+ ref = union(a, b)
392+ mid, at = (a.X+a.W+b.X)/2, cols[i].index
393+ }
394+ if x < mid-pad || x >= mid+pad || y < ref.Y || y >= ref.Y+ref.H {
395+ continue
396+ }
397+ // A lone column next to its own place does not move.
398+ if lone && (at == src || at == src+1) {
399+ return dropTarget{}, true
400+ }
401+ var anchor WindowID
402+ if at < len(w.Columns) {
403+ anchor = w.Columns[at].Windows[0]
404+ if anchor == id {
405+ anchor = w.Columns[at].Windows[1]
406+ }
407+ }
408+ hint := Rect{X: mid - t/2, Y: ref.Y, W: t, H: ref.H}
409+ return dropTarget{kind: dropInsert, anchor: anchor, hints: []Rect{hint}}, true
410+ }
411+ return dropTarget{}, false
412+}
413+
414+// contains reports whether r holds the point x, y.
415+func contains(r Rect, x, y int) bool {
416+ return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H
417+}
418+
419+// union is the smallest rect holding a and b.
420+func union(a, b Rect) Rect {
421+ x, y := min(a.X, b.X), min(a.Y, b.Y)
422+ return Rect{X: x, Y: y, W: max(a.X+a.W, b.X+b.W) - x, H: max(a.Y+a.H, b.Y+b.H) - y}
423+}
424+
425+// outline is the four sides of r, t thick, inside it.
426+func outline(r Rect, t int) []Rect {
427+ t = min(t, r.W/2, r.H/2)
428+ if t <= 0 {
429+ return nil
430+ }
431+ return []Rect{
432+ {X: r.X, Y: r.Y, W: r.W, H: t},
433+ {X: r.X, Y: r.Y + r.H - t, W: r.W, H: t},
434+ {X: r.X, Y: r.Y + t, W: t, H: r.H - 2*t},
435+ {X: r.X + r.W - t, Y: r.Y + t, W: t, H: r.H - 2*t},
436+ }
437+}
438+
439+// drop moves tile id to target t, possibly on another workspace or
440+// screen; focus follows it.
441+func (c *Core) drop(id WindowID, t dropTarget) {
442+ from, src := c.screenOf(id)
443+ if from == nil || t.ws == nil || src.columnOf(id) < 0 {
444+ return
445+ }
446+ w := t.ws
447+ ci := src.columnOf(id)
448+ lone := len(src.Columns[ci].Windows) == 1
449+ if t.kind == dropSwap && w == src && lone {
450+ to := w.columnOf(t.anchor)
451+ // Their fields move with them, as MoveColumn.
452+ w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
453+ w.FocusID(id)
454+ } else {
455+ width := Width{}
456+ if lone {
457+ width = src.Columns[ci].Width
458+ }
459+ srcIndex := ci
460+ src.dropSlotOf(id)
461+ src.RemoveWindow(id)
462+ col := Column{Windows: []WindowID{id}, Width: width}
463+ switch t.kind {
464+ case dropAbove, dropBelow:
465+ ai := w.columnOf(t.anchor)
466+ if ai < 0 {
467+ w.receive(col, -1)
468+ break
469+ }
470+ tc := &w.Columns[ai]
471+ row := slices.Index(tc.Windows, t.anchor)
472+ if t.kind == dropBelow {
473+ row++
474+ }
475+ if row == 0 {
476+ // The slot follows the first window.
477+ tc.Slot = 0
478+ }
479+ tc.Windows = slices.Insert(tc.Windows, row, id)
480+ tc.Shares = nil
481+ w.Activate(id)
482+ default:
483+ at, anchorCol := len(w.Columns), -1
484+ if t.anchor != 0 {
485+ if ai := w.columnOf(t.anchor); ai >= 0 {
486+ at, anchorCol = ai, ai
487+ // A stacked window dropped on a column right of its
488+ // own lands after it.
489+ if t.kind == dropSwap && w == src && ai >= srcIndex {
490+ at = ai + 1
491+ }
492+ }
493+ }
494+ if w.full() {
495+ // No room for a column: it joins the outlined one.
496+ i := min(at, len(w.Columns)-1)
497+ if anchorCol >= 0 {
498+ i = anchorCol
499+ }
500+ w.Columns[i].Windows = append(w.Columns[i].Windows, id)
501+ w.Columns[i].Shares = nil
502+ w.Activate(id)
503+ } else {
504+ w.receive(col, at)
505+ }
506+ }
507+ }
508+ from.mon.normalize()
509+ t.screen.mon.normalize()
510+ c.focusScreen = c.screenIndex(t.screen.name())
511+}
512+
513+// edgeScroll scrolls a scroll-overflow workspace by one column when a
514+// tile drag moves the pointer against its usable edge. It reports whether
515+// the view moved.
516+func (c *Core) edgeScroll(timeMsec uint32) bool {
517+ o, ok := c.layout().At(c.cursorX, c.cursorY)
518+ if !ok || timeMsec-c.drag.scrolled < edgeScrollMsec && c.drag.scrolled != 0 {
519+ return false
520+ }
521+ w := c.screens[c.screenIndex(o.Info.Name)].mon.Current()
522+ if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() {
523+ return false
524+ }
525+ lx := int(c.cursorX) - o.X
526+ dir := 0
527+ switch {
528+ case lx < w.Usable.X+edgeScroll:
529+ dir = -1
530+ case lx >= w.Usable.X+w.Usable.W-edgeScroll:
531+ dir = 1
532+ default:
533+ return false
534+ }
535+ if !w.scrollBy(dir) {
536+ return false
537+ }
538+ c.drag.scrolled = max(timeMsec, 1)
539+ return true
540+}
541+
542+// scrollBy moves the view one column toward dir, within the row; the
543+// focus stays. It reports whether the view moved.
544+func (w *Workspace) scrollBy(dir int) bool {
545+ g := w.gap()
546+ n := len(w.Columns)
547+ maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0)
548+ view := w.ViewX
549+ if dir < 0 {
550+ // The first column not fully on screen at the left.
551+ for i := n - 1; i >= 0; i-- {
552+ if w.columnX(i)-view < w.Usable.X+g {
553+ view = w.columnX(i) - w.Usable.X - g
554+ break
555+ }
556+ }
557+ } else {
558+ for i := range n {
559+ if w.columnX(i)+w.columnWidth(i)-view > w.Usable.X+w.Usable.W-g {
560+ view = w.columnX(i) + w.columnWidth(i) + g - w.Usable.X - w.Usable.W
561+ break
562+ }
563+ }
564+ }
565+ view = min(max(view, 0), maxView)
566+ if view == w.ViewX {
567+ return false
568+ }
569+ w.stopSlide()
570+ w.ViewX = view
571+ return true
572+}
573+
574+// makeFree turns native float f into a free float where it is now.
575+func (w *Workspace) makeFree(f *Float) {
576+ if f.free {
577+ return
578+ }
579+ r := w.floatRect(*f)
580+ if f.W <= 0 || f.H <= 0 {
581+ f.W, f.H = max(r.W-2*w.border, 1), max(r.H-2*w.border, 1)
582+ }
583+ f.free = true
584+ w.moveFree(f, r, 0, 0)
585+}
A internal/core/drag_internal_test.go +148 -0
@@ -0,0 +1,148 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestResized(t *testing.T) {
12+ u := Rect{W: 1000, H: 800}
13+ r := Rect{X: 100, Y: 100, W: 300, H: 200}
14+ for _, tc := range []struct {
15+ edges ports.ResizeEdges
16+ dx, dy int
17+ want Rect
18+ }{
19+ {ports.ResizeRight | ports.ResizeBottom, 50, 20, Rect{X: 100, Y: 100, W: 350, H: 220}},
20+ {ports.ResizeLeft | ports.ResizeTop, 50, 20, Rect{X: 150, Y: 120, W: 250, H: 180}},
21+ {ports.ResizeLeft, -500, 0, Rect{X: 0, Y: 100, W: 400, H: 200}},
22+ {ports.ResizeRight, -1000, 0, Rect{X: 100, Y: 100, W: 64, H: 200}},
23+ {ports.ResizeBottom, 0, 2000, Rect{X: 100, Y: 100, W: 300, H: 700}},
24+ } {
25+ if got := resized(r, tc.edges, tc.dx, tc.dy, 64, u); got != tc.want {
26+ t.Errorf("%v %d,%d: %+v, want %+v", tc.edges, tc.dx, tc.dy, got, tc.want)
27+ }
28+ }
29+}
30+
31+func TestScrollBy(t *testing.T) {
32+ w := workspace()
33+ w.SetOutput(1000, 800)
34+ w.SetGaps(10)
35+ for id := WindowID(1); id <= 4; id++ {
36+ w.AddWindow(id)
37+ }
38+ w.FocusID(1)
39+ if w.ViewX != 0 {
40+ t.Fatal(w.ViewX)
41+ }
42+ if w.scrollBy(-1) {
43+ t.Fatal("scrolled past the start")
44+ }
45+ steps := 0
46+ for w.scrollBy(1) {
47+ steps++
48+ if steps > 10 {
49+ t.Fatal("no end")
50+ }
51+ }
52+ if steps != 2 {
53+ t.Fatal(steps)
54+ }
55+ last := w.columnX(3) + w.columnWidth(3) - w.ViewX
56+ if last != w.Usable.X+w.Usable.W-w.gap() {
57+ t.Fatal(last)
58+ }
59+ // Focus did not change.
60+ if id, _ := w.Focused(); id != 1 {
61+ t.Fatal(id)
62+ }
63+ for w.scrollBy(-1) {
64+ }
65+ if w.ViewX != 0 {
66+ t.Fatal(w.ViewX)
67+ }
68+}
69+
70+func TestDropInHiddenColumns(t *testing.T) {
71+ w := workspace()
72+ w.SetOutput(1000, 800)
73+ w.SetGaps(10)
74+ w.Overflow = OverflowFixed
75+ w.SetMaxColumns(3)
76+ for id := WindowID(1); id <= 3; id++ {
77+ w.AddWindow(id)
78+ }
79+ w.FocusID(2)
80+ w.ToggleFullWidth()
81+ // Columns 1 and 3 are hidden by the maximized one: the point where
82+ // column 3 was hits 2 now, a swap with itself: nothing.
83+ if tgt := w.dropIn(2, 900, 400, 4); tgt.kind != dropNone {
84+ t.Fatalf("%+v", tgt)
85+ }
86+ // Dragging 1 (hidden) over 2: top band stacks above 2.
87+ if tgt := w.dropIn(1, 500, 30, 4); tgt.kind != dropAbove || tgt.anchor != 2 {
88+ t.Fatalf("%+v", tgt)
89+ }
90+}
91+
92+// dragCore is a core with one 1000x600 output and no channels but the
93+// scene and command ones; tests drive its methods directly.
94+func dragCore(t *testing.T) *Core {
95+ t.Helper()
96+ cfg := ports.Config{}
97+ cfg.Keyboard.CmdKey = "super"
98+ cfg.Layout.MaxColumns = 3
99+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 1024)})
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ c.cur().info = ports.OutputInfo{Name: "OUT", Width: 1000, Height: 600}
104+ c.cur().mon.SetOutput(1000, 600)
105+ return c
106+}
107+
108+func TestCancelKeepsFloatRaised(t *testing.T) {
109+ c := dragCore(t)
110+ w := c.cur().mon.Current()
111+ w.AddWindow(1)
112+ w.AddFloating(2, 1000, 600)
113+ w.FocusID(1)
114+ if !w.Floats[0].below {
115+ t.Fatal("not below")
116+ }
117+ if err := c.beginDrag(context.Background(), 2, btnLeft, false, 0); err != nil {
118+ t.Fatal(err)
119+ }
120+ c.cancelDrag()
121+ if w.Floats[0].below {
122+ t.Fatal("escape put the focused float behind the tiles")
123+ }
124+}
125+
126+func TestDropReleasesSlot(t *testing.T) {
127+ c := dragCore(t)
128+ w := c.cur().mon.Current()
129+ w.Columns = []Column{{Windows: []WindowID{1}, Slot: 1}, {Windows: []WindowID{2}}}
130+ key := slotKey{workspace: w.Name, index: 1}
131+ c.slots[key] = &slotState{window: 1}
132+ ctx := context.Background()
133+ if err := c.beginDrag(ctx, 1, btnLeft, false, 0); err != nil {
134+ t.Fatal(err)
135+ }
136+ c.drag.target = dropTarget{}
137+ r, _ := w.placed(2)
138+ c.cursorX, c.cursorY = float64(r.X+r.W/2), float64(r.Y+5)
139+ if err := c.endDrag(ctx); err != nil {
140+ t.Fatal(err)
141+ }
142+ if !slices.Equal(w.Columns[0].Windows, []WindowID{1, 2}) || w.Columns[0].Slot != 0 {
143+ t.Fatal(w.Columns)
144+ }
145+ if c.slots[key].window != 0 {
146+ t.Fatal("slot still holds the window")
147+ }
148+}
@@ -0,0 +1,148 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestResized(t *testing.T) {
12+ u := Rect{W: 1000, H: 800}
13+ r := Rect{X: 100, Y: 100, W: 300, H: 200}
14+ for _, tc := range []struct {
15+ edges ports.ResizeEdges
16+ dx, dy int
17+ want Rect
18+ }{
19+ {ports.ResizeRight | ports.ResizeBottom, 50, 20, Rect{X: 100, Y: 100, W: 350, H: 220}},
20+ {ports.ResizeLeft | ports.ResizeTop, 50, 20, Rect{X: 150, Y: 120, W: 250, H: 180}},
21+ {ports.ResizeLeft, -500, 0, Rect{X: 0, Y: 100, W: 400, H: 200}},
22+ {ports.ResizeRight, -1000, 0, Rect{X: 100, Y: 100, W: 64, H: 200}},
23+ {ports.ResizeBottom, 0, 2000, Rect{X: 100, Y: 100, W: 300, H: 700}},
24+ } {
25+ if got := resized(r, tc.edges, tc.dx, tc.dy, 64, u); got != tc.want {
26+ t.Errorf("%v %d,%d: %+v, want %+v", tc.edges, tc.dx, tc.dy, got, tc.want)
27+ }
28+ }
29+}
30+
31+func TestScrollBy(t *testing.T) {
32+ w := workspace()
33+ w.SetOutput(1000, 800)
34+ w.SetGaps(10)
35+ for id := WindowID(1); id <= 4; id++ {
36+ w.AddWindow(id)
37+ }
38+ w.FocusID(1)
39+ if w.ViewX != 0 {
40+ t.Fatal(w.ViewX)
41+ }
42+ if w.scrollBy(-1) {
43+ t.Fatal("scrolled past the start")
44+ }
45+ steps := 0
46+ for w.scrollBy(1) {
47+ steps++
48+ if steps > 10 {
49+ t.Fatal("no end")
50+ }
51+ }
52+ if steps != 2 {
53+ t.Fatal(steps)
54+ }
55+ last := w.columnX(3) + w.columnWidth(3) - w.ViewX
56+ if last != w.Usable.X+w.Usable.W-w.gap() {
57+ t.Fatal(last)
58+ }
59+ // Focus did not change.
60+ if id, _ := w.Focused(); id != 1 {
61+ t.Fatal(id)
62+ }
63+ for w.scrollBy(-1) {
64+ }
65+ if w.ViewX != 0 {
66+ t.Fatal(w.ViewX)
67+ }
68+}
69+
70+func TestDropInHiddenColumns(t *testing.T) {
71+ w := workspace()
72+ w.SetOutput(1000, 800)
73+ w.SetGaps(10)
74+ w.Overflow = OverflowFixed
75+ w.SetMaxColumns(3)
76+ for id := WindowID(1); id <= 3; id++ {
77+ w.AddWindow(id)
78+ }
79+ w.FocusID(2)
80+ w.ToggleFullWidth()
81+ // Columns 1 and 3 are hidden by the maximized one: the point where
82+ // column 3 was hits 2 now, a swap with itself: nothing.
83+ if tgt := w.dropIn(2, 900, 400, 4); tgt.kind != dropNone {
84+ t.Fatalf("%+v", tgt)
85+ }
86+ // Dragging 1 (hidden) over 2: top band stacks above 2.
87+ if tgt := w.dropIn(1, 500, 30, 4); tgt.kind != dropAbove || tgt.anchor != 2 {
88+ t.Fatalf("%+v", tgt)
89+ }
90+}
91+
92+// dragCore is a core with one 1000x600 output and no channels but the
93+// scene and command ones; tests drive its methods directly.
94+func dragCore(t *testing.T) *Core {
95+ t.Helper()
96+ cfg := ports.Config{}
97+ cfg.Keyboard.CmdKey = "super"
98+ cfg.Layout.MaxColumns = 3
99+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 1024)})
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ c.cur().info = ports.OutputInfo{Name: "OUT", Width: 1000, Height: 600}
104+ c.cur().mon.SetOutput(1000, 600)
105+ return c
106+}
107+
108+func TestCancelKeepsFloatRaised(t *testing.T) {
109+ c := dragCore(t)
110+ w := c.cur().mon.Current()
111+ w.AddWindow(1)
112+ w.AddFloating(2, 1000, 600)
113+ w.FocusID(1)
114+ if !w.Floats[0].below {
115+ t.Fatal("not below")
116+ }
117+ if err := c.beginDrag(context.Background(), 2, btnLeft, false, 0); err != nil {
118+ t.Fatal(err)
119+ }
120+ c.cancelDrag()
121+ if w.Floats[0].below {
122+ t.Fatal("escape put the focused float behind the tiles")
123+ }
124+}
125+
126+func TestDropReleasesSlot(t *testing.T) {
127+ c := dragCore(t)
128+ w := c.cur().mon.Current()
129+ w.Columns = []Column{{Windows: []WindowID{1}, Slot: 1}, {Windows: []WindowID{2}}}
130+ key := slotKey{workspace: w.Name, index: 1}
131+ c.slots[key] = &slotState{window: 1}
132+ ctx := context.Background()
133+ if err := c.beginDrag(ctx, 1, btnLeft, false, 0); err != nil {
134+ t.Fatal(err)
135+ }
136+ c.drag.target = dropTarget{}
137+ r, _ := w.placed(2)
138+ c.cursorX, c.cursorY = float64(r.X+r.W/2), float64(r.Y+5)
139+ if err := c.endDrag(ctx); err != nil {
140+ t.Fatal(err)
141+ }
142+ if !slices.Equal(w.Columns[0].Windows, []WindowID{1, 2}) || w.Columns[0].Slot != 0 {
143+ t.Fatal(w.Columns)
144+ }
145+ if c.slots[key].window != 0 {
146+ t.Fatal("slot still holds the window")
147+ }
148+}
A internal/core/drag_test.go +686 -0
@@ -0,0 +1,686 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "slices"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/adapters/config"
11+ "github.com/bnema/neferwl/internal/core"
12+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13+ "github.com/bnema/neferwl/internal/ports"
14+)
15+
16+type dragRig struct {
17+ client chan ports.ClientEvent
18+ input chan ports.InputEvent
19+ output chan ports.OutputEvent
20+ commands chan ports.ClientCommand
21+ scenes chan []ports.Scene
22+ last []ports.Scene
23+ epoch *atomic.Value
24+}
25+
26+// startDrag runs core with Super as Cmd, no border, 10 px gaps, and
27+// the given outputs. edit may change the config.
28+func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
29+ t.Helper()
30+ cfg := config.Defaults()
31+ cfg.Border.Width = 0
32+ cfg.Layout.Gaps = 10
33+ cfg.Terminal.AutoOpen = "off"
34+ if edit != nil {
35+ edit(&cfg)
36+ }
37+ epoch := &atomic.Value{}
38+ epoch.Store(ports.SecurityState{})
39+ gate := portsmocks.NewMockSessionSecurity(t)
40+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }).Maybe()
41+ // Unbuffered: a send returns once core took the event, so it handled
42+ // every event sent before (settle relies on it).
43+ r := &dragRig{
44+ client: make(chan ports.ClientEvent), input: make(chan ports.InputEvent),
45+ output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
46+ scenes: make(chan []ports.Scene, 1), epoch: epoch,
47+ }
48+ c, err := core.New(cfg, core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes})
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ ctx, cancel := context.WithCancel(context.Background())
53+ done := make(chan error, 1)
54+ go func() { done <- c.Run(ctx) }()
55+ t.Cleanup(func() { cancel(); <-done })
56+ for _, o := range outs {
57+ r.output <- ports.OutputAdded{Info: o}
58+ r.settle(t)
59+ }
60+ return r
61+}
62+
63+// in sends a raw input event; the gate is on, so it is wrapped.
64+func (r *dragRig) in(ev ports.InputEvent) {
65+ send(r.input, ports.InputEvent(ports.SecurityInput{State: r.epoch.Load().(ports.SecurityState), Event: ev}))
66+}
67+
68+// send fails the test binary fast instead of hanging when core is stuck.
69+func send[T any](ch chan T, v T) {
70+ select {
71+ case ch <- v:
72+ case <-time.After(5 * time.Second):
73+ panic("core stuck")
74+ }
75+}
76+
77+// barrier is a window no test maps: a resize of it changes nothing but
78+// makes core publish.
79+const barrier ports.WindowID = 1 << 30
80+
81+// settle returns the scene set once core handled every event sent so
82+// far. The second barrier is taken only after the first one published.
83+func (r *dragRig) settle(t *testing.T) []ports.Scene {
84+ t.Helper()
85+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
86+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
87+ select {
88+ case s := <-r.scenes:
89+ r.last = s
90+ case <-time.After(5 * time.Second):
91+ t.Fatal("no scene")
92+ }
93+ return r.last
94+}
95+
96+// drain returns the commands sent since the last drain.
97+func (r *dragRig) drain() []ports.ClientCommand {
98+ var out []ports.ClientCommand
99+ for {
100+ select {
101+ case v := <-r.commands:
102+ if s, ok := v.(ports.SecurityCommand); ok {
103+ v = s.Command
104+ }
105+ out = append(out, v)
106+ default:
107+ return out
108+ }
109+ }
110+}
111+
112+func pointerSent(cmds []ports.ClientCommand) []ports.ClientCommand {
113+ var out []ports.ClientCommand
114+ for _, v := range cmds {
115+ switch v.(type) {
116+ case ports.PointerButtonTo, ports.PointerMotionTo, ports.ForwardKey:
117+ out = append(out, v)
118+ }
119+ }
120+ return out
121+}
122+
123+func dragRect(t *testing.T, set []ports.Scene, output string, id ports.WindowID) ports.Rect {
124+ t.Helper()
125+ w, ok := windowIn(set, output, id)
126+ if !ok {
127+ t.Fatalf("window %d not on %s", id, output)
128+ }
129+ return w.Rect
130+}
131+
132+func dragScene(set []ports.Scene, output string) ports.Scene {
133+ for _, s := range set {
134+ if s.Output == output {
135+ return s
136+ }
137+ }
138+ return ports.Scene{}
139+}
140+
141+// columns lists the tiles left to right by x, then top to bottom.
142+func columnsOf(set []ports.Scene, output string) [][]ports.WindowID {
143+ s := dragScene(set, output)
144+ var wins []ports.SceneWindow
145+ for _, w := range s.Windows {
146+ if !w.Hidden && !w.Floating && !w.Popup {
147+ wins = append(wins, w)
148+ }
149+ }
150+ slices.SortFunc(wins, func(a, b ports.SceneWindow) int {
151+ if a.Rect.X != b.Rect.X {
152+ return a.Rect.X - b.Rect.X
153+ }
154+ return a.Rect.Y - b.Rect.Y
155+ })
156+ var out [][]ports.WindowID
157+ lastX := -1 << 30
158+ for _, w := range wins {
159+ if w.Rect.X != lastX {
160+ out = append(out, nil)
161+ lastX = w.Rect.X
162+ }
163+ out[len(out)-1] = append(out[len(out)-1], w.ID)
164+ }
165+ return out
166+}
167+
168+var dragOut = ports.OutputInfo{Name: "A", Width: 1000, Height: 600}
169+
170+func (r *dragRig) moveTo(x, y float64, msec uint32) {
171+ r.in(ports.PointerMotion{X: x, Y: y, TimeMsec: msec})
172+}
173+
174+func (r *dragRig) press(t *testing.T, button uint32, mods ports.Mods) {
175+ t.Helper()
176+ if mods != 0 {
177+ r.in(ports.KeyEvent{Keysym: "Super_L", Mods: mods, Pressed: true})
178+ }
179+ r.in(ports.PointerButton{Button: button, Pressed: true})
180+}
181+
182+func TestCmdDragMovesFloatWithoutClientEvents(t *testing.T) {
183+ r := startDragRig(t, nil, dragOut)
184+ r.client <- ports.WindowMapped{ID: 1}
185+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
186+ set := r.settle(t)
187+ f := dragRect(t, set, "A", 2)
188+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
189+ r.settle(t)
190+ r.drain()
191+ r.press(t, 0x110, ports.ModSuper)
192+ r.moveTo(float64(f.X+60), float64(f.Y+40), 2)
193+ set = r.settle(t)
194+ got := dragRect(t, set, "A", 2)
195+ if got.X != f.X+50 || got.Y != f.Y+30 || got.W != f.W {
196+ t.Fatalf("moved to %+v from %+v", got, f)
197+ }
198+ if w, _ := windowIn(set, "A", 2); !w.Floating {
199+ t.Fatal("not floating")
200+ }
201+ r.in(ports.PointerButton{Button: 0x110})
202+ r.in(ports.KeyEvent{Keysym: "Super_L"})
203+ r.settle(t)
204+ cmds := r.drain()
205+ for _, v := range pointerSent(cmds) {
206+ if _, ok := v.(ports.PointerButtonTo); ok {
207+ t.Fatalf("client saw %#v", v)
208+ }
209+ if _, ok := v.(ports.PointerMotionTo); ok {
210+ t.Fatalf("client saw %#v", v)
211+ }
212+ }
213+ // It stays there: a free float now.
214+ r.moveTo(float64(f.X+61), float64(f.Y+41), 3)
215+ if got2 := dragRect(t, r.settle(t), "A", 2); got2 != got {
216+ t.Fatalf("moved after release: %+v", got2)
217+ }
218+ // A plain press goes to the client.
219+ r.drain()
220+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
221+ r.settle(t)
222+ found := false
223+ for _, v := range r.drain() {
224+ if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 && b.Pressed {
225+ found = true
226+ }
227+ }
228+ if !found {
229+ t.Fatal("plain press not forwarded")
230+ }
231+}
232+
233+func TestDragEscapeRestoresFloat(t *testing.T) {
234+ r := startDragRig(t, nil, dragOut)
235+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
236+ f := dragRect(t, r.settle(t), "A", 2)
237+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
238+ r.settle(t)
239+ r.press(t, 0x111, ports.ModSuper)
240+ r.moveTo(float64(f.X+110), float64(f.Y+60), 2)
241+ if got := dragRect(t, r.settle(t), "A", 2); got.W != f.W+100 || got.H != f.H+50 || got.X != f.X {
242+ t.Fatalf("resize: %+v from %+v", got, f)
243+ }
244+ r.drain()
245+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
246+ if got := dragRect(t, r.settle(t), "A", 2); got != f {
247+ t.Fatalf("escape: %+v, want %+v", got, f)
248+ }
249+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
250+ r.in(ports.PointerButton{Button: 0x111})
251+ r.settle(t)
252+ for _, v := range pointerSent(r.drain()) {
253+ t.Fatalf("client saw %#v", v)
254+ }
255+}
256+
257+func TestDragTileZonesAndHints(t *testing.T) {
258+ r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
259+ for id := ports.WindowID(1); id <= 3; id++ {
260+ r.client <- ports.WindowMapped{ID: id}
261+ r.settle(t)
262+ }
263+ set := r.settle(t)
264+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
265+ t.Fatal(got)
266+ }
267+ a, c := dragRect(t, set, "A", 1), dragRect(t, set, "A", 3)
268+ // Drag 1 onto the bottom band of 3: it stacks below 3.
269+ r.moveTo(float64(a.X+a.W/2), float64(a.Y+a.H/2), 1)
270+ r.settle(t)
271+ r.press(t, 0x110, ports.ModSuper)
272+ r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H-5), 2)
273+ set = r.settle(t)
274+ hints := dragScene(set, "A").DropHints
275+ if len(hints) != 1 || hints[0].Y+hints[0].H != c.Y+c.H || hints[0].W != c.W {
276+ t.Fatalf("hints %+v", hints)
277+ }
278+ // Tiles do not move until the drop.
279+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
280+ t.Fatal(got)
281+ }
282+ r.in(ports.PointerButton{Button: 0x110})
283+ set = r.settle(t)
284+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
285+ t.Fatal(got)
286+ }
287+ if len(dragScene(set, "A").DropHints) != 0 {
288+ t.Fatal("hints left")
289+ }
290+ // Centre of 2 with stacked 1 dragged from its right: 1 becomes a
291+ // column in 2's place, 2 moves toward where 1 came from.
292+ b := dragRect(t, set, "A", 2)
293+ one := dragRect(t, set, "A", 1)
294+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
295+ r.settle(t)
296+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
297+ r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 4)
298+ if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {
299+ t.Fatalf("swap outline %+v", hints)
300+ }
301+ r.in(ports.PointerButton{Button: 0x110})
302+ set = r.settle(t)
303+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
304+ t.Fatal(got)
305+ }
306+ // Lone 3 on the centre of 2: the columns swap.
307+ b, c = dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
308+ r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H/2), 5)
309+ r.settle(t)
310+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
311+ r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)
312+ r.settle(t)
313+ r.in(ports.PointerButton{Button: 0x110})
314+ set = r.settle(t)
315+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {
316+ t.Fatal(got)
317+ }
318+ // Escape mid tile drag: nothing moves, no hints.
319+ one = dragRect(t, set, "A", 1)
320+ b = dragRect(t, set, "A", 2)
321+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 7)
322+ r.settle(t)
323+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
324+ r.moveTo(float64(b.X+b.W/2), float64(b.Y+5), 8)
325+ if len(dragScene(r.settle(t), "A").DropHints) == 0 {
326+ t.Fatal("no hint")
327+ }
328+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
329+ set = r.settle(t)
330+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) || len(dragScene(set, "A").DropHints) != 0 {
331+ t.Fatal(got, dragScene(set, "A").DropHints)
332+ }
333+}
334+
335+func TestDragTileGapInsert(t *testing.T) {
336+ r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
337+ for id := ports.WindowID(1); id <= 3; id++ {
338+ r.client <- ports.WindowMapped{ID: id}
339+ r.settle(t)
340+ }
341+ r.client <- ports.WindowMapped{ID: 4}
342+ r.settle(t)
343+ // Stack 4 under 3 with the keyboard, then put 4 between 1 and 2.
344+ r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper, Pressed: true})
345+ r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper})
346+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
347+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
348+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
349+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
350+ set := r.settle(t)
351+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3, 4}}, slices.Equal) {
352+ t.Fatal(got)
353+ }
354+ one, two, four := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 4)
355+ gapX := (one.X + one.W + two.X) / 2
356+ r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
357+ r.settle(t)
358+ r.press(t, 0x110, ports.ModSuper)
359+ r.moveTo(float64(gapX), float64(one.Y+one.H/2), 2)
360+ hints := dragScene(r.settle(t), "A").DropHints
361+ if len(hints) != 1 || hints[0].H != one.H || hints[0].X+hints[0].W/2 != gapX {
362+ t.Fatalf("gap hint %+v", hints)
363+ }
364+ r.in(ports.PointerButton{Button: 0x110})
365+ if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got[:2], [][]ports.WindowID{{1}, {4}}, slices.Equal) {
366+ t.Fatal(got)
367+ }
368+}
369+
370+func TestDragFixedNoGapZone(t *testing.T) {
371+ r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
372+ for id := ports.WindowID(1); id <= 2; id++ {
373+ r.client <- ports.WindowMapped{ID: id}
374+ r.settle(t)
375+ }
376+ set := r.settle(t)
377+ one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
378+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
379+ r.settle(t)
380+ r.press(t, 0x110, ports.ModSuper)
381+ r.moveTo(float64((one.X+one.W+two.X)/2), float64(one.Y+one.H/2), 2)
382+ if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
383+ t.Fatalf("fixed gap hint %+v", hints)
384+ }
385+ // Top band of 2 stacks 1 above it.
386+ r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)
387+ r.settle(t)
388+ r.in(ports.PointerButton{Button: 0x110})
389+ if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {
390+ t.Fatal(got)
391+ }
392+}
393+
394+func TestDragAcrossOutputs(t *testing.T) {
395+ b := ports.OutputInfo{Name: "B", Width: 800, Height: 600}
396+ r := startDragRig(t, nil, dragOut, b)
397+ r.client <- ports.WindowMapped{ID: 1}
398+ r.client <- ports.WindowMapped{ID: 2}
399+ r.settle(t)
400+ // Put 3 on B.
401+ r.moveTo(1500, 300, 1)
402+ r.settle(t)
403+ r.client <- ports.WindowMapped{ID: 3}
404+ set := r.settle(t)
405+ if _, ok := windowIn(set, "B", 3); !ok {
406+ t.Fatal("3 not on B")
407+ }
408+ one := dragRect(t, set, "A", 1)
409+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 2)
410+ r.settle(t)
411+ r.press(t, 0x110, ports.ModSuper)
412+ r.moveTo(1400, 300, 3)
413+ set = r.settle(t)
414+ if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {
415+ t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)
416+ }
417+ r.in(ports.PointerButton{Button: 0x110})
418+ set = r.settle(t)
419+ if _, ok := windowIn(set, "B", 1); !ok {
420+ t.Fatal("1 not on B")
421+ }
422+ if w, _ := windowIn(set, "B", 1); !w.Focused {
423+ t.Fatal("1 not focused")
424+ }
425+}
426+
427+func TestClientMoveRequest(t *testing.T) {
428+ r := startDragRig(t, nil, dragOut)
429+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
430+ f := dragRect(t, r.settle(t), "A", 2)
431+ // Without a held button: ignored.
432+ r.client <- ports.WindowMoveRequest{ID: 2}
433+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
434+ r.settle(t)
435+ r.moveTo(float64(f.X+20), float64(f.Y+10), 2)
436+ if got := dragRect(t, r.settle(t), "A", 2); got != f {
437+ t.Fatalf("moved without a button: %+v", got)
438+ }
439+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
440+ r.settle(t)
441+ r.client <- ports.WindowMoveRequest{ID: 2}
442+ r.settle(t)
443+ r.drain()
444+ r.moveTo(float64(f.X+60), float64(f.Y+10), 3)
445+ if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+40 {
446+ t.Fatalf("move request: %+v from %+v", got, f)
447+ }
448+ r.in(ports.PointerButton{Button: 0x110})
449+ r.settle(t)
450+ for _, v := range pointerSent(r.drain()) {
451+ t.Fatalf("client saw %#v", v)
452+ }
453+}
454+
455+func TestLockClearsDrag(t *testing.T) {
456+ r := startDragRig(t, nil, dragOut)
457+ r.client <- ports.WindowMapped{ID: 1}
458+ r.client <- ports.WindowMapped{ID: 2}
459+ set := r.settle(t)
460+ one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
461+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
462+ r.settle(t)
463+ r.press(t, 0x110, ports.ModSuper)
464+ r.moveTo(float64(two.X+two.W/2), float64(two.Y+5), 2)
465+ if len(dragScene(r.settle(t), "A").DropHints) == 0 {
466+ t.Fatal("no hint")
467+ }
468+ locked := ports.SecurityState{Generation: 1, Protected: true}
469+ r.epoch.Store(locked)
470+ r.client <- ports.SessionLockChanged{State: locked}
471+ set = r.settle(t)
472+ if len(dragScene(set, "A").DropHints) != 0 {
473+ t.Fatal("hints while locked")
474+ }
475+ unlocked := ports.SecurityState{Generation: 2}
476+ r.epoch.Store(unlocked)
477+ r.client <- ports.SessionLockChanged{State: unlocked}
478+ r.settle(t)
479+ r.in(ports.PointerButton{Button: 0x110})
480+ set = r.settle(t)
481+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}}, slices.Equal) {
482+ t.Fatal(got)
483+ }
484+}
485+
486+func TestDragEdgeScroll(t *testing.T) {
487+ r := startDragRig(t, nil, dragOut)
488+ for id := ports.WindowID(1); id <= 4; id++ {
489+ r.client <- ports.WindowMapped{ID: id}
490+ r.settle(t)
491+ }
492+ // Back to the first column: 3 and 4 are off screen to the right.
493+ for range 3 {
494+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
495+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
496+ }
497+ set := r.settle(t)
498+ one := dragRect(t, set, "A", 1)
499+ if one.X != 10 {
500+ t.Fatalf("%+v", one)
501+ }
502+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1000)
503+ r.settle(t)
504+ r.press(t, 0x110, ports.ModSuper)
505+ r.moveTo(995, 300, 1001)
506+ first := dragRect(t, r.settle(t), "A", 1)
507+ if first.X >= one.X {
508+ t.Fatalf("no scroll: %+v", first)
509+ }
510+ // Under 250 ms: no second step.
511+ r.moveTo(996, 300, 1100)
512+ if got := dragRect(t, r.settle(t), "A", 1); got != first {
513+ t.Fatalf("scrolled too soon: %+v", got)
514+ }
515+ r.moveTo(997, 300, 1300)
516+ second := dragRect(t, r.settle(t), "A", 1)
517+ r.moveTo(998, 300, 1600)
518+ r.moveTo(999, 300, 1900)
519+ end := dragRect(t, r.settle(t), "A", 1)
520+ r.moveTo(998, 300, 2200)
521+ if got := dragRect(t, r.settle(t), "A", 1); second.X >= first.X || got != end {
522+ t.Fatalf("first %+v second %+v end %+v got %+v", first, second, end, got)
523+ }
524+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
525+ r.settle(t)
526+}
527+
528+func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
529+ r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
530+ for id := ports.WindowID(1); id <= 3; id++ {
531+ r.client <- ports.WindowMapped{ID: id}
532+ r.settle(t)
533+ }
534+ set := r.settle(t)
535+ one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
536+ // 4 joins 1's column from the top band of 1.
537+ r.client <- ports.WindowMapped{ID: 4}
538+ set = r.settle(t)
539+ four := dragRect(t, set, "A", 4)
540+ r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
541+ r.settle(t)
542+ r.press(t, 0x110, ports.ModSuper)
543+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)
544+ r.settle(t)
545+ r.in(ports.PointerButton{Button: 0x110})
546+ set = r.settle(t)
547+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {
548+ t.Fatal(got)
549+ }
550+ one, two = dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
551+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
552+ r.settle(t)
553+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
554+ r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)
555+ r.settle(t)
556+ r.in(ports.PointerButton{Button: 0x110})
557+ if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {
558+ t.Fatal(got)
559+ }
560+}
561+
562+func TestPressAfterCancelGrabs(t *testing.T) {
563+ r := startDragRig(t, nil, dragOut)
564+ r.client <- ports.WindowMapped{ID: 1}
565+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
566+ f := dragRect(t, r.settle(t), "A", 2)
567+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
568+ r.settle(t)
569+ r.press(t, 0x110, ports.ModSuper)
570+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
571+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
572+ r.in(ports.KeyEvent{Keysym: "Super_L"})
573+ r.settle(t)
574+ r.drain()
575+ // Left is still held, swallowed. A right click on 2 goes to 2, and so
576+ // does its release after the pointer left it.
577+ r.in(ports.PointerButton{Button: 0x111, Pressed: true})
578+ r.moveTo(5, 5, 2)
579+ r.in(ports.PointerButton{Button: 0x111})
580+ r.settle(t)
581+ var got []ports.PointerButtonTo
582+ for _, v := range r.drain() {
583+ if b, ok := v.(ports.PointerButtonTo); ok {
584+ got = append(got, b)
585+ }
586+ }
587+ if len(got) != 2 || got[0].ID != 2 || got[1].ID != 2 || got[1].Pressed {
588+ t.Fatalf("%+v", got)
589+ }
590+}
591+
592+func TestClientResizeWithoutEdgesIgnored(t *testing.T) {
593+ r := startDragRig(t, nil, dragOut)
594+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
595+ f := dragRect(t, r.settle(t), "A", 2)
596+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
597+ r.settle(t)
598+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
599+ r.client <- ports.WindowMoveRequest{ID: 2, Resize: true}
600+ r.moveTo(float64(f.X+60), float64(f.Y+60), 2)
601+ if got := dragRect(t, r.settle(t), "A", 2); got != f {
602+ t.Fatalf("%+v from %+v", got, f)
603+ }
604+}
605+
606+func TestClientDragUsesLastPress(t *testing.T) {
607+ r := startDragRig(t, nil, dragOut)
608+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
609+ f := dragRect(t, r.settle(t), "A", 2)
610+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
611+ r.settle(t)
612+ r.in(ports.PointerButton{Button: 0x111, Pressed: true})
613+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
614+ r.client <- ports.WindowMoveRequest{ID: 2}
615+ r.moveTo(float64(f.X+60), float64(f.Y+10), 2)
616+ r.settle(t)
617+ // Releasing left ends the drag: the window stays put afterwards.
618+ r.in(ports.PointerButton{Button: 0x110})
619+ r.moveTo(float64(f.X+90), float64(f.Y+10), 3)
620+ if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+50 {
621+ t.Fatalf("%+v from %+v", got, f)
622+ }
623+}
624+
625+func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
626+ r := startDragRig(t, nil, dragOut)
627+ for id := ports.WindowID(1); id <= 4; id++ {
628+ r.client <- ports.WindowMapped{ID: id}
629+ r.settle(t)
630+ }
631+ for range 3 {
632+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
633+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
634+ }
635+ // Drag 2 into the top gap, then along it to the right edge: no drop
636+ // target before or after, but the view scrolls.
637+ two := dragRect(t, r.settle(t), "A", 2)
638+ edge := float64(dragOut.Width - 30)
639+ if float64(two.X+two.W) <= edge {
640+ t.Fatalf("2 does not reach the edge: %+v", two)
641+ }
642+ r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 1000)
643+ r.settle(t)
644+ r.press(t, 0x110, ports.ModSuper)
645+ r.moveTo(float64(two.X+two.W/2), 5, 1001)
646+ if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
647+ t.Fatalf("hint in the gap %+v", hints)
648+ }
649+ r.moveTo(edge, 5, 1300)
650+ // An axis event publishes nothing mid-drag: once core took it, the
651+ // motion's scene, if any, is in the channel.
652+ r.in(ports.PointerAxis{})
653+ select {
654+ case set := <-r.scenes:
655+ if got := dragRect(t, set, "A", 2); got.X >= two.X {
656+ t.Fatalf("not scrolled: %+v", got)
657+ }
658+ default:
659+ t.Fatal("edge scroll not published")
660+ }
661+}
662+
663+func TestModsFollowKeys(t *testing.T) {
664+ r := startDragRig(t, nil, dragOut)
665+ r.client <- ports.WindowMapped{ID: 1}
666+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
667+ f := dragRect(t, r.settle(t), "A", 2)
668+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
669+ r.settle(t)
670+ // Super pressed then released: a click is a plain click again.
671+ r.in(ports.KeyEvent{Keysym: "Super_L", Mods: ports.ModSuper, Pressed: true})
672+ r.in(ports.KeyEvent{Keysym: "Super_L"})
673+ r.settle(t)
674+ r.drain()
675+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
676+ r.settle(t)
677+ found := false
678+ for _, v := range r.drain() {
679+ if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 {
680+ found = true
681+ }
682+ }
683+ if !found {
684+ t.Fatal("click after cmd release not forwarded")
685+ }
686+}
@@ -0,0 +1,686 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "slices"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/adapters/config"
11+ "github.com/bnema/neferwl/internal/core"
12+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13+ "github.com/bnema/neferwl/internal/ports"
14+)
15+
16+type dragRig struct {
17+ client chan ports.ClientEvent
18+ input chan ports.InputEvent
19+ output chan ports.OutputEvent
20+ commands chan ports.ClientCommand
21+ scenes chan []ports.Scene
22+ last []ports.Scene
23+ epoch *atomic.Value
24+}
25+
26+// startDrag runs core with Super as Cmd, no border, 10 px gaps, and
27+// the given outputs. edit may change the config.
28+func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
29+ t.Helper()
30+ cfg := config.Defaults()
31+ cfg.Border.Width = 0
32+ cfg.Layout.Gaps = 10
33+ cfg.Terminal.AutoOpen = "off"
34+ if edit != nil {
35+ edit(&cfg)
36+ }
37+ epoch := &atomic.Value{}
38+ epoch.Store(ports.SecurityState{})
39+ gate := portsmocks.NewMockSessionSecurity(t)
40+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }).Maybe()
41+ // Unbuffered: a send returns once core took the event, so it handled
42+ // every event sent before (settle relies on it).
43+ r := &dragRig{
44+ client: make(chan ports.ClientEvent), input: make(chan ports.InputEvent),
45+ output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
46+ scenes: make(chan []ports.Scene, 1), epoch: epoch,
47+ }
48+ c, err := core.New(cfg, core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes})
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ ctx, cancel := context.WithCancel(context.Background())
53+ done := make(chan error, 1)
54+ go func() { done <- c.Run(ctx) }()
55+ t.Cleanup(func() { cancel(); <-done })
56+ for _, o := range outs {
57+ r.output <- ports.OutputAdded{Info: o}
58+ r.settle(t)
59+ }
60+ return r
61+}
62+
63+// in sends a raw input event; the gate is on, so it is wrapped.
64+func (r *dragRig) in(ev ports.InputEvent) {
65+ send(r.input, ports.InputEvent(ports.SecurityInput{State: r.epoch.Load().(ports.SecurityState), Event: ev}))
66+}
67+
68+// send fails the test binary fast instead of hanging when core is stuck.
69+func send[T any](ch chan T, v T) {
70+ select {
71+ case ch <- v:
72+ case <-time.After(5 * time.Second):
73+ panic("core stuck")
74+ }
75+}
76+
77+// barrier is a window no test maps: a resize of it changes nothing but
78+// makes core publish.
79+const barrier ports.WindowID = 1 << 30
80+
81+// settle returns the scene set once core handled every event sent so
82+// far. The second barrier is taken only after the first one published.
83+func (r *dragRig) settle(t *testing.T) []ports.Scene {
84+ t.Helper()
85+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
86+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
87+ select {
88+ case s := <-r.scenes:
89+ r.last = s
90+ case <-time.After(5 * time.Second):
91+ t.Fatal("no scene")
92+ }
93+ return r.last
94+}
95+
96+// drain returns the commands sent since the last drain.
97+func (r *dragRig) drain() []ports.ClientCommand {
98+ var out []ports.ClientCommand
99+ for {
100+ select {
101+ case v := <-r.commands:
102+ if s, ok := v.(ports.SecurityCommand); ok {
103+ v = s.Command
104+ }
105+ out = append(out, v)
106+ default:
107+ return out
108+ }
109+ }
110+}
111+
112+func pointerSent(cmds []ports.ClientCommand) []ports.ClientCommand {
113+ var out []ports.ClientCommand
114+ for _, v := range cmds {
115+ switch v.(type) {
116+ case ports.PointerButtonTo, ports.PointerMotionTo, ports.ForwardKey:
117+ out = append(out, v)
118+ }
119+ }
120+ return out
121+}
122+
123+func dragRect(t *testing.T, set []ports.Scene, output string, id ports.WindowID) ports.Rect {
124+ t.Helper()
125+ w, ok := windowIn(set, output, id)
126+ if !ok {
127+ t.Fatalf("window %d not on %s", id, output)
128+ }
129+ return w.Rect
130+}
131+
132+func dragScene(set []ports.Scene, output string) ports.Scene {
133+ for _, s := range set {
134+ if s.Output == output {
135+ return s
136+ }
137+ }
138+ return ports.Scene{}
139+}
140+
141+// columns lists the tiles left to right by x, then top to bottom.
142+func columnsOf(set []ports.Scene, output string) [][]ports.WindowID {
143+ s := dragScene(set, output)
144+ var wins []ports.SceneWindow
145+ for _, w := range s.Windows {
146+ if !w.Hidden && !w.Floating && !w.Popup {
147+ wins = append(wins, w)
148+ }
149+ }
150+ slices.SortFunc(wins, func(a, b ports.SceneWindow) int {
151+ if a.Rect.X != b.Rect.X {
152+ return a.Rect.X - b.Rect.X
153+ }
154+ return a.Rect.Y - b.Rect.Y
155+ })
156+ var out [][]ports.WindowID
157+ lastX := -1 << 30
158+ for _, w := range wins {
159+ if w.Rect.X != lastX {
160+ out = append(out, nil)
161+ lastX = w.Rect.X
162+ }
163+ out[len(out)-1] = append(out[len(out)-1], w.ID)
164+ }
165+ return out
166+}
167+
168+var dragOut = ports.OutputInfo{Name: "A", Width: 1000, Height: 600}
169+
170+func (r *dragRig) moveTo(x, y float64, msec uint32) {
171+ r.in(ports.PointerMotion{X: x, Y: y, TimeMsec: msec})
172+}
173+
174+func (r *dragRig) press(t *testing.T, button uint32, mods ports.Mods) {
175+ t.Helper()
176+ if mods != 0 {
177+ r.in(ports.KeyEvent{Keysym: "Super_L", Mods: mods, Pressed: true})
178+ }
179+ r.in(ports.PointerButton{Button: button, Pressed: true})
180+}
181+
182+func TestCmdDragMovesFloatWithoutClientEvents(t *testing.T) {
183+ r := startDragRig(t, nil, dragOut)
184+ r.client <- ports.WindowMapped{ID: 1}
185+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
186+ set := r.settle(t)
187+ f := dragRect(t, set, "A", 2)
188+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
189+ r.settle(t)
190+ r.drain()
191+ r.press(t, 0x110, ports.ModSuper)
192+ r.moveTo(float64(f.X+60), float64(f.Y+40), 2)
193+ set = r.settle(t)
194+ got := dragRect(t, set, "A", 2)
195+ if got.X != f.X+50 || got.Y != f.Y+30 || got.W != f.W {
196+ t.Fatalf("moved to %+v from %+v", got, f)
197+ }
198+ if w, _ := windowIn(set, "A", 2); !w.Floating {
199+ t.Fatal("not floating")
200+ }
201+ r.in(ports.PointerButton{Button: 0x110})
202+ r.in(ports.KeyEvent{Keysym: "Super_L"})
203+ r.settle(t)
204+ cmds := r.drain()
205+ for _, v := range pointerSent(cmds) {
206+ if _, ok := v.(ports.PointerButtonTo); ok {
207+ t.Fatalf("client saw %#v", v)
208+ }
209+ if _, ok := v.(ports.PointerMotionTo); ok {
210+ t.Fatalf("client saw %#v", v)
211+ }
212+ }
213+ // It stays there: a free float now.
214+ r.moveTo(float64(f.X+61), float64(f.Y+41), 3)
215+ if got2 := dragRect(t, r.settle(t), "A", 2); got2 != got {
216+ t.Fatalf("moved after release: %+v", got2)
217+ }
218+ // A plain press goes to the client.
219+ r.drain()
220+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
221+ r.settle(t)
222+ found := false
223+ for _, v := range r.drain() {
224+ if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 && b.Pressed {
225+ found = true
226+ }
227+ }
228+ if !found {
229+ t.Fatal("plain press not forwarded")
230+ }
231+}
232+
233+func TestDragEscapeRestoresFloat(t *testing.T) {
234+ r := startDragRig(t, nil, dragOut)
235+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
236+ f := dragRect(t, r.settle(t), "A", 2)
237+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
238+ r.settle(t)
239+ r.press(t, 0x111, ports.ModSuper)
240+ r.moveTo(float64(f.X+110), float64(f.Y+60), 2)
241+ if got := dragRect(t, r.settle(t), "A", 2); got.W != f.W+100 || got.H != f.H+50 || got.X != f.X {
242+ t.Fatalf("resize: %+v from %+v", got, f)
243+ }
244+ r.drain()
245+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
246+ if got := dragRect(t, r.settle(t), "A", 2); got != f {
247+ t.Fatalf("escape: %+v, want %+v", got, f)
248+ }
249+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
250+ r.in(ports.PointerButton{Button: 0x111})
251+ r.settle(t)
252+ for _, v := range pointerSent(r.drain()) {
253+ t.Fatalf("client saw %#v", v)
254+ }
255+}
256+
257+func TestDragTileZonesAndHints(t *testing.T) {
258+ r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
259+ for id := ports.WindowID(1); id <= 3; id++ {
260+ r.client <- ports.WindowMapped{ID: id}
261+ r.settle(t)
262+ }
263+ set := r.settle(t)
264+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
265+ t.Fatal(got)
266+ }
267+ a, c := dragRect(t, set, "A", 1), dragRect(t, set, "A", 3)
268+ // Drag 1 onto the bottom band of 3: it stacks below 3.
269+ r.moveTo(float64(a.X+a.W/2), float64(a.Y+a.H/2), 1)
270+ r.settle(t)
271+ r.press(t, 0x110, ports.ModSuper)
272+ r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H-5), 2)
273+ set = r.settle(t)
274+ hints := dragScene(set, "A").DropHints
275+ if len(hints) != 1 || hints[0].Y+hints[0].H != c.Y+c.H || hints[0].W != c.W {
276+ t.Fatalf("hints %+v", hints)
277+ }
278+ // Tiles do not move until the drop.
279+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
280+ t.Fatal(got)
281+ }
282+ r.in(ports.PointerButton{Button: 0x110})
283+ set = r.settle(t)
284+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
285+ t.Fatal(got)
286+ }
287+ if len(dragScene(set, "A").DropHints) != 0 {
288+ t.Fatal("hints left")
289+ }
290+ // Centre of 2 with stacked 1 dragged from its right: 1 becomes a
291+ // column in 2's place, 2 moves toward where 1 came from.
292+ b := dragRect(t, set, "A", 2)
293+ one := dragRect(t, set, "A", 1)
294+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
295+ r.settle(t)
296+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
297+ r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 4)
298+ if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {
299+ t.Fatalf("swap outline %+v", hints)
300+ }
301+ r.in(ports.PointerButton{Button: 0x110})
302+ set = r.settle(t)
303+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
304+ t.Fatal(got)
305+ }
306+ // Lone 3 on the centre of 2: the columns swap.
307+ b, c = dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
308+ r.moveTo(float64(c.X+c.W/2), float64(c.Y+c.H/2), 5)
309+ r.settle(t)
310+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
311+ r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)
312+ r.settle(t)
313+ r.in(ports.PointerButton{Button: 0x110})
314+ set = r.settle(t)
315+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {
316+ t.Fatal(got)
317+ }
318+ // Escape mid tile drag: nothing moves, no hints.
319+ one = dragRect(t, set, "A", 1)
320+ b = dragRect(t, set, "A", 2)
321+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 7)
322+ r.settle(t)
323+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
324+ r.moveTo(float64(b.X+b.W/2), float64(b.Y+5), 8)
325+ if len(dragScene(r.settle(t), "A").DropHints) == 0 {
326+ t.Fatal("no hint")
327+ }
328+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
329+ set = r.settle(t)
330+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) || len(dragScene(set, "A").DropHints) != 0 {
331+ t.Fatal(got, dragScene(set, "A").DropHints)
332+ }
333+}
334+
335+func TestDragTileGapInsert(t *testing.T) {
336+ r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
337+ for id := ports.WindowID(1); id <= 3; id++ {
338+ r.client <- ports.WindowMapped{ID: id}
339+ r.settle(t)
340+ }
341+ r.client <- ports.WindowMapped{ID: 4}
342+ r.settle(t)
343+ // Stack 4 under 3 with the keyboard, then put 4 between 1 and 2.
344+ r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper, Pressed: true})
345+ r.in(ports.KeyEvent{Keysym: "bracketleft", Mods: ports.ModSuper})
346+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
347+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
348+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
349+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
350+ set := r.settle(t)
351+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3, 4}}, slices.Equal) {
352+ t.Fatal(got)
353+ }
354+ one, two, four := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 4)
355+ gapX := (one.X + one.W + two.X) / 2
356+ r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
357+ r.settle(t)
358+ r.press(t, 0x110, ports.ModSuper)
359+ r.moveTo(float64(gapX), float64(one.Y+one.H/2), 2)
360+ hints := dragScene(r.settle(t), "A").DropHints
361+ if len(hints) != 1 || hints[0].H != one.H || hints[0].X+hints[0].W/2 != gapX {
362+ t.Fatalf("gap hint %+v", hints)
363+ }
364+ r.in(ports.PointerButton{Button: 0x110})
365+ if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got[:2], [][]ports.WindowID{{1}, {4}}, slices.Equal) {
366+ t.Fatal(got)
367+ }
368+}
369+
370+func TestDragFixedNoGapZone(t *testing.T) {
371+ r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
372+ for id := ports.WindowID(1); id <= 2; id++ {
373+ r.client <- ports.WindowMapped{ID: id}
374+ r.settle(t)
375+ }
376+ set := r.settle(t)
377+ one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
378+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
379+ r.settle(t)
380+ r.press(t, 0x110, ports.ModSuper)
381+ r.moveTo(float64((one.X+one.W+two.X)/2), float64(one.Y+one.H/2), 2)
382+ if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
383+ t.Fatalf("fixed gap hint %+v", hints)
384+ }
385+ // Top band of 2 stacks 1 above it.
386+ r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)
387+ r.settle(t)
388+ r.in(ports.PointerButton{Button: 0x110})
389+ if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {
390+ t.Fatal(got)
391+ }
392+}
393+
394+func TestDragAcrossOutputs(t *testing.T) {
395+ b := ports.OutputInfo{Name: "B", Width: 800, Height: 600}
396+ r := startDragRig(t, nil, dragOut, b)
397+ r.client <- ports.WindowMapped{ID: 1}
398+ r.client <- ports.WindowMapped{ID: 2}
399+ r.settle(t)
400+ // Put 3 on B.
401+ r.moveTo(1500, 300, 1)
402+ r.settle(t)
403+ r.client <- ports.WindowMapped{ID: 3}
404+ set := r.settle(t)
405+ if _, ok := windowIn(set, "B", 3); !ok {
406+ t.Fatal("3 not on B")
407+ }
408+ one := dragRect(t, set, "A", 1)
409+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 2)
410+ r.settle(t)
411+ r.press(t, 0x110, ports.ModSuper)
412+ r.moveTo(1400, 300, 3)
413+ set = r.settle(t)
414+ if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {
415+ t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)
416+ }
417+ r.in(ports.PointerButton{Button: 0x110})
418+ set = r.settle(t)
419+ if _, ok := windowIn(set, "B", 1); !ok {
420+ t.Fatal("1 not on B")
421+ }
422+ if w, _ := windowIn(set, "B", 1); !w.Focused {
423+ t.Fatal("1 not focused")
424+ }
425+}
426+
427+func TestClientMoveRequest(t *testing.T) {
428+ r := startDragRig(t, nil, dragOut)
429+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
430+ f := dragRect(t, r.settle(t), "A", 2)
431+ // Without a held button: ignored.
432+ r.client <- ports.WindowMoveRequest{ID: 2}
433+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
434+ r.settle(t)
435+ r.moveTo(float64(f.X+20), float64(f.Y+10), 2)
436+ if got := dragRect(t, r.settle(t), "A", 2); got != f {
437+ t.Fatalf("moved without a button: %+v", got)
438+ }
439+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
440+ r.settle(t)
441+ r.client <- ports.WindowMoveRequest{ID: 2}
442+ r.settle(t)
443+ r.drain()
444+ r.moveTo(float64(f.X+60), float64(f.Y+10), 3)
445+ if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+40 {
446+ t.Fatalf("move request: %+v from %+v", got, f)
447+ }
448+ r.in(ports.PointerButton{Button: 0x110})
449+ r.settle(t)
450+ for _, v := range pointerSent(r.drain()) {
451+ t.Fatalf("client saw %#v", v)
452+ }
453+}
454+
455+func TestLockClearsDrag(t *testing.T) {
456+ r := startDragRig(t, nil, dragOut)
457+ r.client <- ports.WindowMapped{ID: 1}
458+ r.client <- ports.WindowMapped{ID: 2}
459+ set := r.settle(t)
460+ one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
461+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1)
462+ r.settle(t)
463+ r.press(t, 0x110, ports.ModSuper)
464+ r.moveTo(float64(two.X+two.W/2), float64(two.Y+5), 2)
465+ if len(dragScene(r.settle(t), "A").DropHints) == 0 {
466+ t.Fatal("no hint")
467+ }
468+ locked := ports.SecurityState{Generation: 1, Protected: true}
469+ r.epoch.Store(locked)
470+ r.client <- ports.SessionLockChanged{State: locked}
471+ set = r.settle(t)
472+ if len(dragScene(set, "A").DropHints) != 0 {
473+ t.Fatal("hints while locked")
474+ }
475+ unlocked := ports.SecurityState{Generation: 2}
476+ r.epoch.Store(unlocked)
477+ r.client <- ports.SessionLockChanged{State: unlocked}
478+ r.settle(t)
479+ r.in(ports.PointerButton{Button: 0x110})
480+ set = r.settle(t)
481+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}}, slices.Equal) {
482+ t.Fatal(got)
483+ }
484+}
485+
486+func TestDragEdgeScroll(t *testing.T) {
487+ r := startDragRig(t, nil, dragOut)
488+ for id := ports.WindowID(1); id <= 4; id++ {
489+ r.client <- ports.WindowMapped{ID: id}
490+ r.settle(t)
491+ }
492+ // Back to the first column: 3 and 4 are off screen to the right.
493+ for range 3 {
494+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
495+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
496+ }
497+ set := r.settle(t)
498+ one := dragRect(t, set, "A", 1)
499+ if one.X != 10 {
500+ t.Fatalf("%+v", one)
501+ }
502+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 1000)
503+ r.settle(t)
504+ r.press(t, 0x110, ports.ModSuper)
505+ r.moveTo(995, 300, 1001)
506+ first := dragRect(t, r.settle(t), "A", 1)
507+ if first.X >= one.X {
508+ t.Fatalf("no scroll: %+v", first)
509+ }
510+ // Under 250 ms: no second step.
511+ r.moveTo(996, 300, 1100)
512+ if got := dragRect(t, r.settle(t), "A", 1); got != first {
513+ t.Fatalf("scrolled too soon: %+v", got)
514+ }
515+ r.moveTo(997, 300, 1300)
516+ second := dragRect(t, r.settle(t), "A", 1)
517+ r.moveTo(998, 300, 1600)
518+ r.moveTo(999, 300, 1900)
519+ end := dragRect(t, r.settle(t), "A", 1)
520+ r.moveTo(998, 300, 2200)
521+ if got := dragRect(t, r.settle(t), "A", 1); second.X >= first.X || got != end {
522+ t.Fatalf("first %+v second %+v end %+v got %+v", first, second, end, got)
523+ }
524+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
525+ r.settle(t)
526+}
527+
528+func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
529+ r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
530+ for id := ports.WindowID(1); id <= 3; id++ {
531+ r.client <- ports.WindowMapped{ID: id}
532+ r.settle(t)
533+ }
534+ set := r.settle(t)
535+ one, two := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
536+ // 4 joins 1's column from the top band of 1.
537+ r.client <- ports.WindowMapped{ID: 4}
538+ set = r.settle(t)
539+ four := dragRect(t, set, "A", 4)
540+ r.moveTo(float64(four.X+four.W/2), float64(four.Y+four.H/2), 1)
541+ r.settle(t)
542+ r.press(t, 0x110, ports.ModSuper)
543+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)
544+ r.settle(t)
545+ r.in(ports.PointerButton{Button: 0x110})
546+ set = r.settle(t)
547+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {
548+ t.Fatal(got)
549+ }
550+ one, two = dragRect(t, set, "A", 1), dragRect(t, set, "A", 2)
551+ r.moveTo(float64(one.X+one.W/2), float64(one.Y+one.H/2), 3)
552+ r.settle(t)
553+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
554+ r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)
555+ r.settle(t)
556+ r.in(ports.PointerButton{Button: 0x110})
557+ if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {
558+ t.Fatal(got)
559+ }
560+}
561+
562+func TestPressAfterCancelGrabs(t *testing.T) {
563+ r := startDragRig(t, nil, dragOut)
564+ r.client <- ports.WindowMapped{ID: 1}
565+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
566+ f := dragRect(t, r.settle(t), "A", 2)
567+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
568+ r.settle(t)
569+ r.press(t, 0x110, ports.ModSuper)
570+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper, Pressed: true})
571+ r.in(ports.KeyEvent{Keysym: "Escape", Mods: ports.ModSuper})
572+ r.in(ports.KeyEvent{Keysym: "Super_L"})
573+ r.settle(t)
574+ r.drain()
575+ // Left is still held, swallowed. A right click on 2 goes to 2, and so
576+ // does its release after the pointer left it.
577+ r.in(ports.PointerButton{Button: 0x111, Pressed: true})
578+ r.moveTo(5, 5, 2)
579+ r.in(ports.PointerButton{Button: 0x111})
580+ r.settle(t)
581+ var got []ports.PointerButtonTo
582+ for _, v := range r.drain() {
583+ if b, ok := v.(ports.PointerButtonTo); ok {
584+ got = append(got, b)
585+ }
586+ }
587+ if len(got) != 2 || got[0].ID != 2 || got[1].ID != 2 || got[1].Pressed {
588+ t.Fatalf("%+v", got)
589+ }
590+}
591+
592+func TestClientResizeWithoutEdgesIgnored(t *testing.T) {
593+ r := startDragRig(t, nil, dragOut)
594+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
595+ f := dragRect(t, r.settle(t), "A", 2)
596+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
597+ r.settle(t)
598+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
599+ r.client <- ports.WindowMoveRequest{ID: 2, Resize: true}
600+ r.moveTo(float64(f.X+60), float64(f.Y+60), 2)
601+ if got := dragRect(t, r.settle(t), "A", 2); got != f {
602+ t.Fatalf("%+v from %+v", got, f)
603+ }
604+}
605+
606+func TestClientDragUsesLastPress(t *testing.T) {
607+ r := startDragRig(t, nil, dragOut)
608+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
609+ f := dragRect(t, r.settle(t), "A", 2)
610+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
611+ r.settle(t)
612+ r.in(ports.PointerButton{Button: 0x111, Pressed: true})
613+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
614+ r.client <- ports.WindowMoveRequest{ID: 2}
615+ r.moveTo(float64(f.X+60), float64(f.Y+10), 2)
616+ r.settle(t)
617+ // Releasing left ends the drag: the window stays put afterwards.
618+ r.in(ports.PointerButton{Button: 0x110})
619+ r.moveTo(float64(f.X+90), float64(f.Y+10), 3)
620+ if got := dragRect(t, r.settle(t), "A", 2); got.X != f.X+50 {
621+ t.Fatalf("%+v from %+v", got, f)
622+ }
623+}
624+
625+func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
626+ r := startDragRig(t, nil, dragOut)
627+ for id := ports.WindowID(1); id <= 4; id++ {
628+ r.client <- ports.WindowMapped{ID: id}
629+ r.settle(t)
630+ }
631+ for range 3 {
632+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true})
633+ r.in(ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper})
634+ }
635+ // Drag 2 into the top gap, then along it to the right edge: no drop
636+ // target before or after, but the view scrolls.
637+ two := dragRect(t, r.settle(t), "A", 2)
638+ edge := float64(dragOut.Width - 30)
639+ if float64(two.X+two.W) <= edge {
640+ t.Fatalf("2 does not reach the edge: %+v", two)
641+ }
642+ r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 1000)
643+ r.settle(t)
644+ r.press(t, 0x110, ports.ModSuper)
645+ r.moveTo(float64(two.X+two.W/2), 5, 1001)
646+ if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 0 {
647+ t.Fatalf("hint in the gap %+v", hints)
648+ }
649+ r.moveTo(edge, 5, 1300)
650+ // An axis event publishes nothing mid-drag: once core took it, the
651+ // motion's scene, if any, is in the channel.
652+ r.in(ports.PointerAxis{})
653+ select {
654+ case set := <-r.scenes:
655+ if got := dragRect(t, set, "A", 2); got.X >= two.X {
656+ t.Fatalf("not scrolled: %+v", got)
657+ }
658+ default:
659+ t.Fatal("edge scroll not published")
660+ }
661+}
662+
663+func TestModsFollowKeys(t *testing.T) {
664+ r := startDragRig(t, nil, dragOut)
665+ r.client <- ports.WindowMapped{ID: 1}
666+ r.client <- ports.WindowMapped{ID: 2, Floating: true, Width: 200, Height: 100}
667+ f := dragRect(t, r.settle(t), "A", 2)
668+ r.moveTo(float64(f.X+10), float64(f.Y+10), 1)
669+ r.settle(t)
670+ // Super pressed then released: a click is a plain click again.
671+ r.in(ports.KeyEvent{Keysym: "Super_L", Mods: ports.ModSuper, Pressed: true})
672+ r.in(ports.KeyEvent{Keysym: "Super_L"})
673+ r.settle(t)
674+ r.drain()
675+ r.in(ports.PointerButton{Button: 0x110, Pressed: true})
676+ r.settle(t)
677+ found := false
678+ for _, v := range r.drain() {
679+ if b, ok := v.(ports.PointerButtonTo); ok && b.ID == 2 {
680+ found = true
681+ }
682+ }
683+ if !found {
684+ t.Fatal("click after cmd release not forwarded")
685+ }
686+}
M internal/core/input.go +45 -7
@@ -26,7 +26,8 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
2626 // The focused screen follows the pointer, so new windows
2727 // and launchers open where the user is.
2828 // Only a pointer entering another output switches: keyboard
29- // moves to another screen stick until then. Not mid-drag.
29+ // moves to another screen stick until then. Not mid-drag of a
30+ // client; a window drag (drag.go) follows the pointer.
3031 if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 {
3132 c.pointerOutput = o.Info.Name
3233 if o.Info.Name != c.cur().name() {
@@ -36,6 +37,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
3637 }
3738 }
3839 }
40+ if c.drag != nil {
41+ // The dragged window and the others get no pointer events.
42+ return c.dragMotion(ctx, v.TimeMsec)
43+ }
3944 id, x, y := c.hit(c.cursorX, c.cursorY)
4045 // Layout changes are intentionally re-hit-tested only on motion.
4146 if id != c.pointer {
@@ -52,7 +57,29 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
5257 }
5358 return nil
5459 case ports.PointerButton:
55- if v.Pressed && c.pointer == 0 && c.grab == 0 && len(c.buttons) == 0 {
60+ // Buttons a drag took stay in c.buttons until released, so the
61+ // security gate admits their release; no client sees it.
62+ if !v.Pressed && c.swallow[v.Button] {
63+ delete(c.swallow, v.Button)
64+ delete(c.buttons, v.Button)
65+ return nil
66+ }
67+ if d := c.drag; d != nil {
68+ if !v.Pressed && v.Button == d.button {
69+ delete(c.buttons, v.Button)
70+ return c.endDrag(ctx)
71+ }
72+ // Other buttons do nothing mid-drag; their releases neither.
73+ if v.Pressed {
74+ c.buttons[v.Button] = true
75+ c.swallow[v.Button] = true
76+ }
77+ return nil
78+ }
79+ if started, err := c.startDrag(ctx, v); err != nil || started {
80+ return err
81+ }
82+ if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {
5683 if picked, err := c.overviewClick(ctx); err != nil {
5784 return err
5885 } else if picked {
@@ -63,17 +90,18 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
6390 if c.grab != 0 {
6491 id = c.grab
6592 }
66- if v.Pressed && len(c.buttons) == 0 {
93+ if v.Pressed && c.held() == 0 {
6794 // A click outside an open menu closes it.
6895 if err := c.dismissGrabs(ctx, id); err != nil {
6996 return err
7097 }
7198 }
7299 if v.Pressed {
73- if len(c.buttons) == 0 {
100+ if c.held() == 0 {
74101 c.grab = id
75102 }
76103 c.buttons[v.Button] = true
104+ c.lastButton = v.Button
77105 } else {
78106 delete(c.buttons, v.Button)
79107 }
@@ -103,7 +131,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
103131 }
104132 }
105133 }
106- if len(c.buttons) == 0 {
134+ if c.held() == 0 {
107135 c.grab = 0
108136 }
109137 return nil
@@ -121,8 +149,8 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
121149 return nil
122150 }
123151 // Scroll goes to the window under the pointer, which has the
124- // pointer focus even mid-drag.
125- if c.pointer != 0 {
152+ // pointer focus even mid-drag. None during a window drag.
153+ if c.pointer != 0 && c.drag == nil {
126154 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
127155 return err
128156 }
@@ -145,6 +173,16 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
145173 if !ok {
146174 return nil
147175 }
176+ c.mods = key.Mods
177+ if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
178+ // Escape cancels the drag; its release goes nowhere either.
179+ c.cancelDrag()
180+ c.pressed[heldKey(key)] = true
181+ if err := c.publish(ctx); err != nil {
182+ return err
183+ }
184+ return c.rehit(ctx)
185+ }
148186 // The overview takes its keys before any window; others still
149187 // run binds, and are not forwarded.
150188 // A launcher or a menu holding the keyboard gets them first.
@@ -26,7 +26,8 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
26 // The focused screen follows the pointer, so new windows26 // The focused screen follows the pointer, so new windows
27 // and launchers open where the user is.27 // and launchers open where the user is.
28 // Only a pointer entering another output switches: keyboard28 // Only a pointer entering another output switches: keyboard
29- // moves to another screen stick until then. Not mid-drag.29+ // moves to another screen stick until then. Not mid-drag of a
30+ // client; a window drag (drag.go) follows the pointer.
30 if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 {31 if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 {
31 c.pointerOutput = o.Info.Name32 c.pointerOutput = o.Info.Name
32 if o.Info.Name != c.cur().name() {33 if o.Info.Name != c.cur().name() {
@@ -36,6 +37,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
36 }37 }
37 }38 }
38 }39 }
40+ if c.drag != nil {
41+ // The dragged window and the others get no pointer events.
42+ return c.dragMotion(ctx, v.TimeMsec)
43+ }
39 id, x, y := c.hit(c.cursorX, c.cursorY)44 id, x, y := c.hit(c.cursorX, c.cursorY)
40 // Layout changes are intentionally re-hit-tested only on motion.45 // Layout changes are intentionally re-hit-tested only on motion.
41 if id != c.pointer {46 if id != c.pointer {
@@ -52,7 +57,29 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
52 }57 }
53 return nil58 return nil
54 case ports.PointerButton:59 case ports.PointerButton:
55- if v.Pressed && c.pointer == 0 && c.grab == 0 && len(c.buttons) == 0 {60+ // Buttons a drag took stay in c.buttons until released, so the
61+ // security gate admits their release; no client sees it.
62+ if !v.Pressed && c.swallow[v.Button] {
63+ delete(c.swallow, v.Button)
64+ delete(c.buttons, v.Button)
65+ return nil
66+ }
67+ if d := c.drag; d != nil {
68+ if !v.Pressed && v.Button == d.button {
69+ delete(c.buttons, v.Button)
70+ return c.endDrag(ctx)
71+ }
72+ // Other buttons do nothing mid-drag; their releases neither.
73+ if v.Pressed {
74+ c.buttons[v.Button] = true
75+ c.swallow[v.Button] = true
76+ }
77+ return nil
78+ }
79+ if started, err := c.startDrag(ctx, v); err != nil || started {
80+ return err
81+ }
82+ if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {
56 if picked, err := c.overviewClick(ctx); err != nil {83 if picked, err := c.overviewClick(ctx); err != nil {
57 return err84 return err
58 } else if picked {85 } else if picked {
@@ -63,17 +90,18 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
63 if c.grab != 0 {90 if c.grab != 0 {
64 id = c.grab91 id = c.grab
65 }92 }
66- if v.Pressed && len(c.buttons) == 0 {93+ if v.Pressed && c.held() == 0 {
67 // A click outside an open menu closes it.94 // A click outside an open menu closes it.
68 if err := c.dismissGrabs(ctx, id); err != nil {95 if err := c.dismissGrabs(ctx, id); err != nil {
69 return err96 return err
70 }97 }
71 }98 }
72 if v.Pressed {99 if v.Pressed {
73- if len(c.buttons) == 0 {100+ if c.held() == 0 {
74 c.grab = id101 c.grab = id
75 }102 }
76 c.buttons[v.Button] = true103 c.buttons[v.Button] = true
104+ c.lastButton = v.Button
77 } else {105 } else {
78 delete(c.buttons, v.Button)106 delete(c.buttons, v.Button)
79 }107 }
@@ -103,7 +131,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
103 }131 }
104 }132 }
105 }133 }
106- if len(c.buttons) == 0 {134+ if c.held() == 0 {
107 c.grab = 0135 c.grab = 0
108 }136 }
109 return nil137 return nil
@@ -121,8 +149,8 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
121 return nil149 return nil
122 }150 }
123 // Scroll goes to the window under the pointer, which has the151 // Scroll goes to the window under the pointer, which has the
124- // pointer focus even mid-drag.152+ // pointer focus even mid-drag. None during a window drag.
125- if c.pointer != 0 {153+ if c.pointer != 0 && c.drag == nil {
126 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {154 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
127 return err155 return err
128 }156 }
@@ -145,6 +173,16 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
145 if !ok {173 if !ok {
146 return nil174 return nil
147 }175 }
176+ c.mods = key.Mods
177+ if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
178+ // Escape cancels the drag; its release goes nowhere either.
179+ c.cancelDrag()
180+ c.pressed[heldKey(key)] = true
181+ if err := c.publish(ctx); err != nil {
182+ return err
183+ }
184+ return c.rehit(ctx)
185+ }
148 // The overview takes its keys before any window; others still186 // The overview takes its keys before any window; others still
149 // run binds, and are not forwarded.187 // run binds, and are not forwarded.
150 // A launcher or a menu holding the keyboard gets them first.188 // A launcher or a menu holding the keyboard gets them first.
M internal/core/security.go +3 -1
@@ -24,6 +24,7 @@ func (c *Core) syncSecurity() bool {
2424 c.lockSurfaces, c.lockFocus = nil, 0
2525 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}
2626 c.pointer, c.grab, c.pointerOutput = 0, 0, ""
27+ c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
2728 c.pointerAt, c.motionMsec = [2]float64{}, 0
2829 c.keyboard = keyboard{}
2930 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}
@@ -85,7 +86,8 @@ func blockedProtectedEvent(ev ports.ClientEvent) bool {
8586 switch ev.(type) {
8687 case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained,
8788 ports.PopupRequest, ports.ShortcutsInhibit, ports.WindowFullscreenRequest,
88- ports.CaptureSessionOpen, ports.CaptureFrameTaken, ports.CaptureExclusionBegin, ports.CaptureExclusionLayer:
89+ ports.CaptureSessionOpen, ports.CaptureFrameTaken, ports.CaptureExclusionBegin, ports.CaptureExclusionLayer,
90+ ports.WindowMoveRequest:
8991 return true
9092 }
9193 return false
@@ -24,6 +24,7 @@ func (c *Core) syncSecurity() bool {
24 c.lockSurfaces, c.lockFocus = nil, 024 c.lockSurfaces, c.lockFocus = nil, 0
25 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}25 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}
26 c.pointer, c.grab, c.pointerOutput = 0, 0, ""26 c.pointer, c.grab, c.pointerOutput = 0, 0, ""
27+ c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
27 c.pointerAt, c.motionMsec = [2]float64{}, 028 c.pointerAt, c.motionMsec = [2]float64{}, 0
28 c.keyboard = keyboard{}29 c.keyboard = keyboard{}
29 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}30 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}
@@ -85,7 +86,8 @@ func blockedProtectedEvent(ev ports.ClientEvent) bool {
85 switch ev.(type) {86 switch ev.(type) {
86 case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained,87 case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained,
87 ports.PopupRequest, ports.ShortcutsInhibit, ports.WindowFullscreenRequest,88 ports.PopupRequest, ports.ShortcutsInhibit, ports.WindowFullscreenRequest,
88- ports.CaptureSessionOpen, ports.CaptureFrameTaken, ports.CaptureExclusionBegin, ports.CaptureExclusionLayer:89+ ports.CaptureSessionOpen, ports.CaptureFrameTaken, ports.CaptureExclusionBegin, ports.CaptureExclusionLayer,
90+ ports.WindowMoveRequest:
89 return true91 return true
90 }92 }
91 return false93 return false
M internal/ports/client_events.go +22 -0
@@ -67,6 +67,28 @@ type OutputPower struct {
6767
6868 func (OutputPower) clientEvent() {}
6969
70+// ResizeEdges are the window edges a pointer resize moves, as bit flags
71+// (the xdg_toplevel resize_edge values).
72+type ResizeEdges uint32
73+
74+const (
75+ ResizeTop ResizeEdges = 1
76+ ResizeBottom ResizeEdges = 2
77+ ResizeLeft ResizeEdges = 4
78+ ResizeRight ResizeEdges = 8
79+)
80+
81+// WindowMoveRequest carries wayland → core xdg_toplevel move and resize
82+// requests answering the client's current press: core starts a pointer
83+// drag of the window with the held button.
84+type WindowMoveRequest struct {
85+ ID WindowID
86+ Resize bool
87+ Edges ResizeEdges
88+}
89+
90+func (WindowMoveRequest) clientEvent() {}
91+
7092 // WindowFullscreenRequest carries wayland → core fullscreen requests.
7193 // External is set when another client (a taskbar, through foreign
7294 // toplevel) asks for it, not the window itself.
@@ -67,6 +67,28 @@ type OutputPower struct {
67 67
68 func (OutputPower) clientEvent() {}68 func (OutputPower) clientEvent() {}
69 69
70+// ResizeEdges are the window edges a pointer resize moves, as bit flags
71+// (the xdg_toplevel resize_edge values).
72+type ResizeEdges uint32
73+
74+const (
75+ ResizeTop ResizeEdges = 1
76+ ResizeBottom ResizeEdges = 2
77+ ResizeLeft ResizeEdges = 4
78+ ResizeRight ResizeEdges = 8
79+)
80+
81+// WindowMoveRequest carries wayland → core xdg_toplevel move and resize
82+// requests answering the client's current press: core starts a pointer
83+// drag of the window with the held button.
84+type WindowMoveRequest struct {
85+ ID WindowID
86+ Resize bool
87+ Edges ResizeEdges
88+}
89+
90+func (WindowMoveRequest) clientEvent() {}
91+
70 // WindowFullscreenRequest carries wayland → core fullscreen requests.92 // WindowFullscreenRequest carries wayland → core fullscreen requests.
71 // External is set when another client (a taskbar, through foreign93 // External is set when another client (a taskbar, through foreign
72 // toplevel) asks for it, not the window itself.94 // toplevel) asks for it, not the window itself.
M internal/ports/scene.go +4 -0
@@ -33,6 +33,10 @@ type Scene struct {
3333 // the Border colors: tile lines over the tiles, under the floats; a
3434 // float's border right after the float.
3535 Separators []Separator
36+ // DropHints show where a dragged tile lands, filled with Border.Active
37+ // over the windows of the workspace, under the top layers. Only the
38+ // output under the pointer has them, during a drag.
39+ DropHints []Rect
3640 // Layers are the shown layer surfaces, drawn in slice order: background
3741 // and bottom before windows, top and overlay after. Core leaves out
3842 // those a fullscreen window hides (all but the background and a surface
@@ -33,6 +33,10 @@ type Scene struct {
33 // the Border colors: tile lines over the tiles, under the floats; a33 // the Border colors: tile lines over the tiles, under the floats; a
34 // float's border right after the float.34 // float's border right after the float.
35 Separators []Separator35 Separators []Separator
36+ // DropHints show where a dragged tile lands, filled with Border.Active
37+ // over the windows of the workspace, under the top layers. Only the
38+ // output under the pointer has them, during a drag.
39+ DropHints []Rect
36 // Layers are the shown layer surfaces, drawn in slice order: background40 // Layers are the shown layer surfaces, drawn in slice order: background
37 // and bottom before windows, top and overlay after. Core leaves out41 // and bottom before windows, top and overlay after. Core leaves out
38 // those a fullscreen window hides (all but the background and a surface42 // those a fullscreen window hides (all but the background and a surface

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