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 Unverified
B5690EEEBB952194The 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 | |||
| 155 | 155 | ||
| 156 | 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 | 170 | ### Overview | |
| 159 | 171 | ||
| 160 | 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. | |
| @@ -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 | ### Overview | 170 | ### 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 | |||
| 94 | 94 | // float on the plane would take the veil with it. | |
| 95 | 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 | 101 | for _, l := range s.Layers { | |
| 98 | 102 | if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 { | |
| 99 | 103 | 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 | |||
| 103 | 103 | if full == nil { | |
| 104 | 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 | 110 | if full.Dim > 0 { | |
| 107 | 111 | // Its veil is composed over it: the plane would show it bright. | |
| 108 | 112 | 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 | |||
| 40 | 40 | } | |
| 41 | 41 | w.windows() | |
| 42 | 42 | w.popups(false) | |
| 43 | + | w.dropHints() | |
| 43 | 44 | w.bounds = outputBounds | |
| 44 | 45 | w.layers(true) | |
| 45 | 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 | 100 | // pill fills a rounded square (physical pixels) with three rects: a full | |
| 86 | 101 | // middle and two bands narrowed by the corner radius. | |
| 87 | 102 | 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 = outputBounds | 44 | 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 full | 100 | // 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) { | |||
| 322 | 322 | } | |
| 323 | 323 | } | |
| 324 | 324 | 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 | + | } | |
| 327 | 345 | func (t top) SetMaxSize(r *xdgshell.Toplevel, w, h int32) { | |
| 328 | 346 | if w < 0 || h < 0 { | |
| 329 | 347 | 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 { | |||
| 67 | 67 | key string | |
| 68 | 68 | } | |
| 69 | 69 | 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 | |
| 87 | 96 | cursorX, cursorY float64 // global, logical | |
| 88 | 97 | pointerOutput string // output under the pointer at the last motion | |
| 89 | 98 | // constrained is the constraint wayland activated; constraint is it | |
| @@ -260,6 +269,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 260 | 269 | } | |
| 261 | 270 | c.cfg = cfg | |
| 262 | 271 | c.binds = binds | |
| 272 | + | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | |
| 263 | 273 | c.specs, c.presets = named, presets | |
| 264 | 274 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |
| 265 | 275 | c.named() | |
| @@ -329,7 +339,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 329 | 339 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | |
| 330 | 340 | } | |
| 331 | 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 | 343 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | |
| 334 | 344 | if err := c.apply(cfg); err != nil { | |
| 335 | 345 | return nil, err | |
| @@ -520,6 +530,9 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 520 | 530 | } | |
| 521 | 531 | } | |
| 522 | 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 | 536 | // Only the focused output lights the focused window's lines. | |
| 524 | 537 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen) | |
| 525 | 538 | if sc.mon.ov.open { | |
| @@ -621,7 +634,7 @@ func (c *Core) slid(ctx context.Context, shown bool) error { | |||
| 621 | 634 | // rehit points the pointer at what now lies under a still cursor, as | |
| 622 | 635 | // windows slide under it. A held button keeps its grab. | |
| 623 | 636 | func (c *Core) rehit(ctx context.Context) error { | |
| 624 | - | if c.grab != 0 { | |
| 637 | + | if c.grab != 0 || c.drag != nil { | |
| 625 | 638 | return nil | |
| 626 | 639 | } | |
| 627 | 640 | id, x, y := c.hit(c.cursorX, c.cursorY) | |
| @@ -999,6 +1012,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 999 | 1012 | return nil | |
| 1000 | 1013 | } | |
| 1001 | 1014 | c.windows.drop(v.ID) | |
| 1015 | + | if c.drag != nil && c.drag.id == v.ID { | |
| 1016 | + | c.abortDrag() | |
| 1017 | + | } | |
| 1002 | 1018 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 1003 | 1019 | s.mon.RemoveWindow(v.ID) | |
| 1004 | 1020 | } | |
| @@ -1015,6 +1031,10 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1015 | 1031 | if c.warpPointer(ctx, v) != nil { | |
| 1016 | 1032 | return nil | |
| 1017 | 1033 | } | |
| 1034 | + | case ports.WindowMoveRequest: | |
| 1035 | + | if c.clientDrag(ctx, v) != nil { | |
| 1036 | + | return nil | |
| 1037 | + | } | |
| 1018 | 1038 | case ports.WindowFullscreenRequest: | |
| 1019 | 1039 | c.configures.answer[v.ID] = true | |
| 1020 | 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 | 1118 | c.setLayers(c.allLayers()) | |
| 1099 | 1119 | } | |
| 1100 | 1120 | case ports.OutputRemoved: | |
| 1121 | + | // A drop target may be on the output going away. | |
| 1122 | + | c.abortDrag() | |
| 1101 | 1123 | c.removeScreen(v.Name) | |
| 1102 | 1124 | if c.layerChanged { | |
| 1103 | 1125 | c.setLayers(c.allLayers()) | |
| @@ -67,23 +67,32 @@ type binding struct { | |||||
| 67 | key string | 67 | key string | ||
| 68 | } | 68 | } | ||
| 69 | type Core struct { | 69 | type Core struct { | ||
| 70 | - | security ports.SecurityState | 70 | + | security ports.SecurityState |
| 71 | - | lockSurfaces []ports.LockSurfacePlacement | 71 | + | lockSurfaces []ports.LockSurfacePlacement |
| 72 | - | lockFocus WindowID | 72 | + | lockFocus WindowID |
| 73 | - | inputKeys map[string]bool | 73 | + | inputKeys map[string]bool |
| 74 | - | inputActive bool | 74 | + | inputActive bool |
| 75 | - | startup [][]string | 75 | + | startup [][]string |
| 76 | - | nextWorkspaceID uint64 | 76 | + | nextWorkspaceID uint64 |
| 77 | - | ch Channels | 77 | + | ch Channels |
| 78 | - | cfg ports.Config | 78 | + | cfg ports.Config |
| 79 | - | screens []*screen | 79 | + | screens []*screen |
| 80 | - | focusScreen int | 80 | + | focusScreen int |
| 81 | - | binds map[binding]Action | 81 | + | binds map[binding]Action |
| 82 | - | pressed map[string]bool | 82 | + | pressed map[string]bool |
| 83 | - | configures configures | 83 | + | configures configures |
| 84 | - | pointer WindowID | 84 | + | pointer WindowID |
| 85 | - | grab WindowID | 85 | + | grab WindowID |
| 86 | - | buttons map[uint32]bool | 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 | |||
| 87 | cursorX, cursorY float64 // global, logical | 96 | cursorX, cursorY float64 // global, logical | ||
| 88 | pointerOutput string // output under the pointer at the last motion | 97 | pointerOutput string // output under the pointer at the last motion | ||
| 89 | // constrained is the constraint wayland activated; constraint is it | 98 | // 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 = cfg | 270 | c.cfg = cfg | ||
| 262 | c.binds = binds | 271 | 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, presets | 273 | 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, err | 345 | 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, as | 634 | // 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 nil | 638 | 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 nil | 1012 | 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 nil | 1032 | 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] = true | 1039 | 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 { | |||
| 26 | 26 | // The focused screen follows the pointer, so new windows | |
| 27 | 27 | // and launchers open where the user is. | |
| 28 | 28 | // 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 | 31 | if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 { | |
| 31 | 32 | c.pointerOutput = o.Info.Name | |
| 32 | 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 | 44 | id, x, y := c.hit(c.cursorX, c.cursorY) | |
| 40 | 45 | // Layout changes are intentionally re-hit-tested only on motion. | |
| 41 | 46 | if id != c.pointer { | |
| @@ -52,7 +57,29 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 52 | 57 | } | |
| 53 | 58 | return nil | |
| 54 | 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 | 83 | if picked, err := c.overviewClick(ctx); err != nil { | |
| 57 | 84 | return err | |
| 58 | 85 | } else if picked { | |
| @@ -63,17 +90,18 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 63 | 90 | if c.grab != 0 { | |
| 64 | 91 | id = c.grab | |
| 65 | 92 | } | |
| 66 | - | if v.Pressed && len(c.buttons) == 0 { | |
| 93 | + | if v.Pressed && c.held() == 0 { | |
| 67 | 94 | // A click outside an open menu closes it. | |
| 68 | 95 | if err := c.dismissGrabs(ctx, id); err != nil { | |
| 69 | 96 | return err | |
| 70 | 97 | } | |
| 71 | 98 | } | |
| 72 | 99 | if v.Pressed { | |
| 73 | - | if len(c.buttons) == 0 { | |
| 100 | + | if c.held() == 0 { | |
| 74 | 101 | c.grab = id | |
| 75 | 102 | } | |
| 76 | 103 | c.buttons[v.Button] = true | |
| 104 | + | c.lastButton = v.Button | |
| 77 | 105 | } else { | |
| 78 | 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 | 135 | c.grab = 0 | |
| 108 | 136 | } | |
| 109 | 137 | return nil | |
| @@ -121,8 +149,8 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 121 | 149 | return nil | |
| 122 | 150 | } | |
| 123 | 151 | // 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 { | |
| 126 | 154 | if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil { | |
| 127 | 155 | return err | |
| 128 | 156 | } | |
| @@ -145,6 +173,16 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 145 | 173 | if !ok { | |
| 146 | 174 | 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 | 186 | // The overview takes its keys before any window; others still | |
| 149 | 187 | // run binds, and are not forwarded. | |
| 150 | 188 | // 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 windows | 26 | // 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: keyboard | 28 | // 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.Name | 32 | 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 nil | 58 | 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 err | 84 | 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.grab | 91 | 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 err | 96 | 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 = id | 101 | c.grab = id | ||
| 75 | } | 102 | } | ||
| 76 | c.buttons[v.Button] = true | 103 | 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 = 0 | 135 | c.grab = 0 | ||
| 108 | } | 136 | } | ||
| 109 | return nil | 137 | return nil | ||
| @@ -121,8 +149,8 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 121 | return nil | 149 | return nil | ||
| 122 | } | 150 | } | ||
| 123 | // Scroll goes to the window under the pointer, which has the | 151 | // 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 err | 155 | 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 nil | 174 | 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 still | 186 | // 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 { | |||
| 24 | 24 | c.lockSurfaces, c.lockFocus = nil, 0 | |
| 25 | 25 | c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{} | |
| 26 | 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 | 28 | c.pointerAt, c.motionMsec = [2]float64{}, 0 | |
| 28 | 29 | c.keyboard = keyboard{} | |
| 29 | 30 | c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{} | |
| @@ -85,7 +86,8 @@ func blockedProtectedEvent(ev ports.ClientEvent) bool { | |||
| 85 | 86 | switch ev.(type) { | |
| 86 | 87 | case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained, | |
| 87 | 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 | 91 | return true | |
| 90 | 92 | } | |
| 91 | 93 | return false | |
| @@ -24,6 +24,7 @@ func (c *Core) syncSecurity() bool { | |||||
| 24 | c.lockSurfaces, c.lockFocus = nil, 0 | 24 | 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{}, 0 | 28 | 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 true | 91 | return true | ||
| 90 | } | 92 | } | ||
| 91 | return false | 93 | return false | ||
M internal/ports/client_events.go +22 -0
| @@ -67,6 +67,28 @@ type OutputPower struct { | |||
| 67 | 67 | ||
| 68 | 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 | 92 | // WindowFullscreenRequest carries wayland → core fullscreen requests. | |
| 71 | 93 | // External is set when another client (a taskbar, through foreign | |
| 72 | 94 | // 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 foreign | 93 | // 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 { | |||
| 33 | 33 | // the Border colors: tile lines over the tiles, under the floats; a | |
| 34 | 34 | // float's border right after the float. | |
| 35 | 35 | 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 | 40 | // Layers are the shown layer surfaces, drawn in slice order: background | |
| 37 | 41 | // and bottom before windows, top and overlay after. Core leaves out | |
| 38 | 42 | // 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; a | 33 | // 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 []Separator | 35 | 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: background | 40 | // Layers are the shown layer surfaces, drawn in slice order: background | ||
| 37 | // and bottom before windows, top and overlay after. Core leaves out | 41 | // and bottom before windows, top and overlay after. Core leaves out | ||
| 38 | // those a fullscreen window hides (all but the background and a surface | 42 | // those a fullscreen window hides (all but the background and a surface | ||