detached 318e2d8

Merge branch 'feat/pointer-grab-resize' into main (!10)

feat(core): resize tiled columns and rows by dragging their border
commit
318e2d8f6cdaf0ffe532e301c7687aab321afc34
author
ermite · · 2026-10-21 07:17 UTC
merge of
672ba53 + f2fc42d · diff against the first parent
signature
SSH VerifiedSHA256:lY9fILbB/A+hln6Vh/iHoiiog74bbw/4qWrf/lrTU3U · ermite instance key Verified for the committer email
checks
1/1
31 files · +1449 -57
M docs/config.md +3 -1
@@ -332,7 +332,9 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
332332
333333 - `cmd` + left drag moves a window. Drop it on the top or bottom of another window to stack it there, or in the middle to swap.
334334 - `cmd` + right drag resizes a floating window.
335-- `Escape` cancels the drag.
335+- `cmd` + right drag on the line between two columns or two stacked windows resizes them.
336+- `cmd` + double right-click on that line resets the lane's column widths or the column's row heights. Column widths reset when a column enters or leaves the lane.
337+- `Escape` cancels the drag and restores the previous sizes.
336338
337339 ### Overview
338340
@@ -332,7 +332,9 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
332 332
333 - `cmd` + left drag moves a window. Drop it on the top or bottom of another window to stack it there, or in the middle to swap.333 - `cmd` + left drag moves a window. Drop it on the top or bottom of another window to stack it there, or in the middle to swap.
334 - `cmd` + right drag resizes a floating window.334 - `cmd` + right drag resizes a floating window.
335-- `Escape` cancels the drag.335+- `cmd` + right drag on the line between two columns or two stacked windows resizes them.
336+- `cmd` + double right-click on that line resets the lane's column widths or the column's row heights. Column widths reset when a column enters or leaves the lane.
337+- `Escape` cancels the drag and restores the previous sizes.
336 338
337 ### Overview339 ### Overview
338 340
M docs/dev/headless.md +1 -0
@@ -41,6 +41,7 @@ The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`
4141 | `type <text>` | Type text |
4242 | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super |
4343 | `hold Super+h 1s` | Press a key like `key`, hold it, then release it |
44+| `press Super`, `release Super` | Hold or release a modifier across mouse actions (`Super`, `Shift`, `Ctrl`, `Alt`) |
4445 | `sleep 1s` | Wait |
4546 | `move <x> <y>` | Move the pointer, in output coordinates |
4647 | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons |
@@ -41,6 +41,7 @@ The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`
41 | `type <text>` | Type text |41 | `type <text>` | Type text |
42 | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super |42 | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super |
43 | `hold Super+h 1s` | Press a key like `key`, hold it, then release it |43 | `hold Super+h 1s` | Press a key like `key`, hold it, then release it |
44+| `press Super`, `release Super` | Hold or release a modifier across mouse actions (`Super`, `Shift`, `Ctrl`, `Alt`) |
44 | `sleep 1s` | Wait |45 | `sleep 1s` | Wait |
45 | `move <x> <y>` | Move the pointer, in output coordinates |46 | `move <x> <y>` | Move the pointer, in output coordinates |
46 | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons |47 | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons |
M internal/adapters/headlessinput/input.go +24 -0
@@ -166,6 +166,30 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
166166 return 0
167167 }
168168 now := monotonic()
169+ // Separate modifier edges allow mouse actions while a modifier is held.
170+ if len(fields) > 0 && (fields[0] == "press" || fields[0] == "release") {
171+ var code uint32
172+ if len(fields) == 2 {
173+ switch fields[1] {
174+ case "Super":
175+ code = 125
176+ case "Shift":
177+ code = 42
178+ case "Ctrl":
179+ code = 29
180+ case "Alt":
181+ code = 56
182+ }
183+ }
184+ if code == 0 {
185+ warnInput("line", line, "invalid modifier")
186+ continue
187+ }
188+ if err := emit(code, fields[0] == "press"); err != nil {
189+ return err
190+ }
191+ continue
192+ }
169193 if len(fields) > 0 && fields[0] == "move" {
170194 if len(fields) == 3 {
171195 x, ex := strconv.ParseFloat(fields[1], 64)
@@ -166,6 +166,30 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
166 return 0166 return 0
167 }167 }
168 now := monotonic()168 now := monotonic()
169+ // Separate modifier edges allow mouse actions while a modifier is held.
170+ if len(fields) > 0 && (fields[0] == "press" || fields[0] == "release") {
171+ var code uint32
172+ if len(fields) == 2 {
173+ switch fields[1] {
174+ case "Super":
175+ code = 125
176+ case "Shift":
177+ code = 42
178+ case "Ctrl":
179+ code = 29
180+ case "Alt":
181+ code = 56
182+ }
183+ }
184+ if code == 0 {
185+ warnInput("line", line, "invalid modifier")
186+ continue
187+ }
188+ if err := emit(code, fields[0] == "press"); err != nil {
189+ return err
190+ }
191+ continue
192+ }
169 if len(fields) > 0 && fields[0] == "move" {193 if len(fields) > 0 && fields[0] == "move" {
170 if len(fields) == 3 {194 if len(fields) == 3 {
171 x, ex := strconv.ParseFloat(fields[1], 64)195 x, ex := strconv.ParseFloat(fields[1], 64)
M internal/adapters/headlessinput/input_test.go +49 -0
@@ -23,6 +23,14 @@ func TestScript(t *testing.T) {
2323 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},
2424 {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}},
2525 {"hold h", nil, nil},
26+ {"press Super", []string{"Super_L"}, []uint32{125}},
27+ {"release Super", []string{"Super_L"}, []uint32{125}},
28+ {"press Shift", []string{"Shift_L"}, []uint32{42}},
29+ {"press Ctrl", []string{"Control_L"}, []uint32{29}},
30+ {"press Alt", []string{"Alt_L"}, []uint32{56}},
31+ {"press", nil, nil},
32+ {"release Super extra", nil, nil},
33+ {"press unknown", nil, nil},
2634 } {
2735 t.Run(tt.line, func(t *testing.T) {
2836 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
@@ -61,6 +69,47 @@ func TestScript(t *testing.T) {
6169 }
6270 }
6371
72+func TestModifierAcrossMouseActions(t *testing.T) {
73+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ script := make(chan string, 8)
78+ for _, line := range []string{"press Super", "move 500 100", "down right", "move 700 100", "up right", "release Super"} {
79+ script <- line
80+ }
81+ close(script)
82+ input := make(chan ports.InputEvent, 16)
83+ if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil {
84+ t.Fatal(err)
85+ }
86+ if len(input) != 6 {
87+ t.Fatal(len(input))
88+ }
89+ press := (<-input).(ports.KeyEvent)
90+ if !press.Pressed || press.Mods&ports.ModSuper == 0 {
91+ t.Fatal(press)
92+ }
93+ if _, ok := (<-input).(ports.PointerMotion); !ok {
94+ t.Fatal("missing motion")
95+ }
96+ down := (<-input).(ports.PointerButton)
97+ if down.Button != 0x111 || !down.Pressed {
98+ t.Fatal(down)
99+ }
100+ if _, ok := (<-input).(ports.PointerMotion); !ok {
101+ t.Fatal("missing drag motion")
102+ }
103+ up := (<-input).(ports.PointerButton)
104+ if up.Button != 0x111 || up.Pressed {
105+ t.Fatal(up)
106+ }
107+ release := (<-input).(ports.KeyEvent)
108+ if release.Pressed || release.Mods&ports.ModSuper != 0 {
109+ t.Fatal(release)
110+ }
111+}
112+
64113 // hold keeps the key and its modifiers down for the given duration, then
65114 // releases the key before the modifier.
66115 func TestHoldKeepsKeyDown(t *testing.T) {
@@ -23,6 +23,14 @@ func TestScript(t *testing.T) {
23 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},23 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},
24 {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}},24 {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}},
25 {"hold h", nil, nil},25 {"hold h", nil, nil},
26+ {"press Super", []string{"Super_L"}, []uint32{125}},
27+ {"release Super", []string{"Super_L"}, []uint32{125}},
28+ {"press Shift", []string{"Shift_L"}, []uint32{42}},
29+ {"press Ctrl", []string{"Control_L"}, []uint32{29}},
30+ {"press Alt", []string{"Alt_L"}, []uint32{56}},
31+ {"press", nil, nil},
32+ {"release Super extra", nil, nil},
33+ {"press unknown", nil, nil},
26 } {34 } {
27 t.Run(tt.line, func(t *testing.T) {35 t.Run(tt.line, func(t *testing.T) {
28 km, err := xkb.New(xkb.RMLVO{Layout: "us"})36 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
@@ -61,6 +69,47 @@ func TestScript(t *testing.T) {
61 }69 }
62 }70 }
63 71
72+func TestModifierAcrossMouseActions(t *testing.T) {
73+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ script := make(chan string, 8)
78+ for _, line := range []string{"press Super", "move 500 100", "down right", "move 700 100", "up right", "release Super"} {
79+ script <- line
80+ }
81+ close(script)
82+ input := make(chan ports.InputEvent, 16)
83+ if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil {
84+ t.Fatal(err)
85+ }
86+ if len(input) != 6 {
87+ t.Fatal(len(input))
88+ }
89+ press := (<-input).(ports.KeyEvent)
90+ if !press.Pressed || press.Mods&ports.ModSuper == 0 {
91+ t.Fatal(press)
92+ }
93+ if _, ok := (<-input).(ports.PointerMotion); !ok {
94+ t.Fatal("missing motion")
95+ }
96+ down := (<-input).(ports.PointerButton)
97+ if down.Button != 0x111 || !down.Pressed {
98+ t.Fatal(down)
99+ }
100+ if _, ok := (<-input).(ports.PointerMotion); !ok {
101+ t.Fatal("missing drag motion")
102+ }
103+ up := (<-input).(ports.PointerButton)
104+ if up.Button != 0x111 || up.Pressed {
105+ t.Fatal(up)
106+ }
107+ release := (<-input).(ports.KeyEvent)
108+ if release.Pressed || release.Mods&ports.ModSuper != 0 {
109+ t.Fatal(release)
110+ }
111+}
112+
64 // hold keeps the key and its modifiers down for the given duration, then113 // hold keeps the key and its modifiers down for the given duration, then
65 // releases the key before the modifier.114 // releases the key before the modifier.
66 func TestHoldKeepsKeyDown(t *testing.T) {115 func TestHoldKeepsKeyDown(t *testing.T) {
M internal/adapters/wayland/cursor.go +26 -2
@@ -138,9 +138,33 @@ func (s *surface) cursorImage() ports.CursorChange {
138138 return ports.CursorChange{Image: &img, Scale: scale}
139139 }
140140
141-// setCursor queues a cursor change; only the latest is delivered.
141+// setCursor remembers the client cursor, forwarding it unless overridden.
142142 func (s *Server) setCursor(c ports.CursorChange) {
143- if s.protected() || s.channels.Cursors == nil {
143+ if s.protected() {
144+ return
145+ }
146+ s.cursorClient = c
147+ if s.cursorOverride == "" {
148+ s.queueCursor(c)
149+ }
150+}
151+
152+func (s *Server) setCursorOverride(shape string) {
153+ if s.protected() || shape == s.cursorOverride {
154+ return
155+ }
156+ s.cursorOverride = shape
157+ s.log.Debug().Str("shape", shape).Msg("cursor override")
158+ if shape == "" {
159+ s.queueCursor(s.cursorClient)
160+ } else {
161+ s.queueCursor(ports.CursorChange{Shape: shape})
162+ }
163+}
164+
165+// queueCursor queues a cursor change; only the latest is delivered.
166+func (s *Server) queueCursor(c ports.CursorChange) {
167+ if s.channels.Cursors == nil {
144168 return
145169 }
146170 s.cursorMu.Lock()
@@ -138,9 +138,33 @@ func (s *surface) cursorImage() ports.CursorChange {
138 return ports.CursorChange{Image: &img, Scale: scale}138 return ports.CursorChange{Image: &img, Scale: scale}
139 }139 }
140 140
141-// setCursor queues a cursor change; only the latest is delivered.141+// setCursor remembers the client cursor, forwarding it unless overridden.
142 func (s *Server) setCursor(c ports.CursorChange) {142 func (s *Server) setCursor(c ports.CursorChange) {
143- if s.protected() || s.channels.Cursors == nil {143+ if s.protected() {
144+ return
145+ }
146+ s.cursorClient = c
147+ if s.cursorOverride == "" {
148+ s.queueCursor(c)
149+ }
150+}
151+
152+func (s *Server) setCursorOverride(shape string) {
153+ if s.protected() || shape == s.cursorOverride {
154+ return
155+ }
156+ s.cursorOverride = shape
157+ s.log.Debug().Str("shape", shape).Msg("cursor override")
158+ if shape == "" {
159+ s.queueCursor(s.cursorClient)
160+ } else {
161+ s.queueCursor(ports.CursorChange{Shape: shape})
162+ }
163+}
164+
165+// queueCursor queues a cursor change; only the latest is delivered.
166+func (s *Server) queueCursor(c ports.CursorChange) {
167+ if s.channels.Cursors == nil {
144 return168 return
145 }169 }
146 s.cursorMu.Lock()170 s.cursorMu.Lock()
M internal/adapters/wayland/cursor_test.go +48 -0
@@ -83,6 +83,23 @@ func TestCursorShapeFollowsPointerFocus(t *testing.T) {
8383 if got := nextCursor(t, cursors); got.Shape != "pointer" {
8484 t.Fatalf("shape: %+v", got)
8585 }
86+ commands <- ports.CursorOverride{Shape: "ew-resize"}
87+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "ew-resize"}) {
88+ t.Fatalf("override: %+v", got)
89+ }
90+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(3), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
91+ if err := c.Roundtrip(); err != nil {
92+ t.Fatal(err)
93+ }
94+ select {
95+ case got := <-cursors:
96+ t.Fatalf("client cursor forwarded during override: %+v", got)
97+ case <-time.After(50 * time.Millisecond):
98+ }
99+ commands <- ports.CursorOverride{}
100+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "text"}) {
101+ t.Fatalf("restored client cursor: %+v", got)
102+ }
86103 commands <- ports.PointerFocus{ID: 0}
87104 if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
88105 t.Fatalf("leave: %+v, want the arrow", got)
@@ -205,3 +222,34 @@ func TestSetCursorRoleError(t *testing.T) {
205222 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))
206223 expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))
207224 }
225+
226+func TestCursorOverrideProtection(t *testing.T) {
227+ s, _, commands, cursors, _ := cursorServer(t)
228+ state := installSecurity(t, s)
229+ commands <- ports.CursorOverride{Shape: "ns-resize"}
230+ if got := nextCursor(t, cursors); got.Shape != "ns-resize" {
231+ t.Fatalf("override: %+v", got)
232+ }
233+ protect(t, s, state, 1, true)
234+ if !s.display.Do(func() {
235+ s.resetProtectionInput()
236+ s.apply(ports.SecurityCommand{State: *state.Load(), Command: ports.CursorOverride{Shape: "ew-resize"}})
237+ if s.cursorOverride != "" {
238+ t.Errorf("protected override: %q", s.cursorOverride)
239+ }
240+ }) {
241+ t.Fatal("display stopped")
242+ }
243+ select {
244+ case got := <-cursors:
245+ t.Fatalf("protected cursor forwarded: %+v", got)
246+ case <-time.After(50 * time.Millisecond):
247+ }
248+ protect(t, s, state, 2, false)
249+ if !s.display.Do(func() { s.setCursor(ports.CursorChange{Hidden: true}) }) {
250+ t.Fatal("display stopped")
251+ }
252+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Hidden: true}) {
253+ t.Fatalf("override survived protection: %+v", got)
254+ }
255+}
@@ -83,6 +83,23 @@ func TestCursorShapeFollowsPointerFocus(t *testing.T) {
83 if got := nextCursor(t, cursors); got.Shape != "pointer" {83 if got := nextCursor(t, cursors); got.Shape != "pointer" {
84 t.Fatalf("shape: %+v", got)84 t.Fatalf("shape: %+v", got)
85 }85 }
86+ commands <- ports.CursorOverride{Shape: "ew-resize"}
87+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "ew-resize"}) {
88+ t.Fatalf("override: %+v", got)
89+ }
90+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(3), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
91+ if err := c.Roundtrip(); err != nil {
92+ t.Fatal(err)
93+ }
94+ select {
95+ case got := <-cursors:
96+ t.Fatalf("client cursor forwarded during override: %+v", got)
97+ case <-time.After(50 * time.Millisecond):
98+ }
99+ commands <- ports.CursorOverride{}
100+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "text"}) {
101+ t.Fatalf("restored client cursor: %+v", got)
102+ }
86 commands <- ports.PointerFocus{ID: 0}103 commands <- ports.PointerFocus{ID: 0}
87 if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {104 if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
88 t.Fatalf("leave: %+v, want the arrow", got)105 t.Fatalf("leave: %+v, want the arrow", got)
@@ -205,3 +222,34 @@ func TestSetCursorRoleError(t *testing.T) {
205 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))222 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))
206 expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))223 expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))
207 }224 }
225+
226+func TestCursorOverrideProtection(t *testing.T) {
227+ s, _, commands, cursors, _ := cursorServer(t)
228+ state := installSecurity(t, s)
229+ commands <- ports.CursorOverride{Shape: "ns-resize"}
230+ if got := nextCursor(t, cursors); got.Shape != "ns-resize" {
231+ t.Fatalf("override: %+v", got)
232+ }
233+ protect(t, s, state, 1, true)
234+ if !s.display.Do(func() {
235+ s.resetProtectionInput()
236+ s.apply(ports.SecurityCommand{State: *state.Load(), Command: ports.CursorOverride{Shape: "ew-resize"}})
237+ if s.cursorOverride != "" {
238+ t.Errorf("protected override: %q", s.cursorOverride)
239+ }
240+ }) {
241+ t.Fatal("display stopped")
242+ }
243+ select {
244+ case got := <-cursors:
245+ t.Fatalf("protected cursor forwarded: %+v", got)
246+ case <-time.After(50 * time.Millisecond):
247+ }
248+ protect(t, s, state, 2, false)
249+ if !s.display.Do(func() { s.setCursor(ports.CursorChange{Hidden: true}) }) {
250+ t.Fatal("display stopped")
251+ }
252+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Hidden: true}) {
253+ t.Fatalf("override survived protection: %+v", got)
254+ }
255+}
M internal/adapters/wayland/server.go +6 -1
@@ -288,6 +288,9 @@ type Server struct {
288288 fractions map[*surface]*fractionalscale.WpFractionalScaleV1
289289 // cursorSurface is the wl_pointer.set_cursor surface in use.
290290 cursorSurface *surface
291+ // cursorClient and cursorOverride belong to the display goroutine.
292+ cursorClient ports.CursorChange
293+ cursorOverride string
291294 // cursorMu guards only the latest cursor change for forwardCursors.
292295 cursorMu sync.Mutex
293296 cursorLatest ports.CursorChange
@@ -751,12 +754,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
751754 // Only input/focus commands require an owner envelope. Config relay
752755 // also produces SetKeymap and other epoch-independent commands.
753756 switch cmd.(type) {
754- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,
757+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, ports.CursorOverride,
755758 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
756759 return
757760 }
758761 }
759762 switch c := cmd.(type) {
763+ case ports.CursorOverride:
764+ s.setCursorOverride(c.Shape)
760765 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
761766 s.applyInput(c)
762767 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
@@ -288,6 +288,9 @@ type Server struct {
288 fractions map[*surface]*fractionalscale.WpFractionalScaleV1288 fractions map[*surface]*fractionalscale.WpFractionalScaleV1
289 // cursorSurface is the wl_pointer.set_cursor surface in use.289 // cursorSurface is the wl_pointer.set_cursor surface in use.
290 cursorSurface *surface290 cursorSurface *surface
291+ // cursorClient and cursorOverride belong to the display goroutine.
292+ cursorClient ports.CursorChange
293+ cursorOverride string
291 // cursorMu guards only the latest cursor change for forwardCursors.294 // cursorMu guards only the latest cursor change for forwardCursors.
292 cursorMu sync.Mutex295 cursorMu sync.Mutex
293 cursorLatest ports.CursorChange296 cursorLatest ports.CursorChange
@@ -751,12 +754,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
751 // Only input/focus commands require an owner envelope. Config relay754 // Only input/focus commands require an owner envelope. Config relay
752 // also produces SetKeymap and other epoch-independent commands.755 // also produces SetKeymap and other epoch-independent commands.
753 switch cmd.(type) {756 switch cmd.(type) {
754- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,757+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, ports.CursorOverride,
755 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:758 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
756 return759 return
757 }760 }
758 }761 }
759 switch c := cmd.(type) {762 switch c := cmd.(type) {
763+ case ports.CursorOverride:
764+ s.setCursorOverride(c.Shape)
760 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:765 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
761 s.applyInput(c)766 s.applyInput(c)
762 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:767 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
M internal/adapters/wayland/session_lock.go +1 -0
@@ -138,6 +138,7 @@ func (l *sessionLock) orphan() {
138138 }
139139
140140 func (s *Server) resetProtectionInput() {
141+ s.cursorOverride = ""
141142 s.dndCancelAll()
142143 s.changeFocus(0)
143144 s.changePointerFocus(0, 0, 0)
@@ -138,6 +138,7 @@ func (l *sessionLock) orphan() {
138 }138 }
139 139
140 func (s *Server) resetProtectionInput() {140 func (s *Server) resetProtectionInput() {
141+ s.cursorOverride = ""
141 s.dndCancelAll()142 s.dndCancelAll()
142 s.changeFocus(0)143 s.changeFocus(0)
143 s.changePointerFocus(0, 0, 0)144 s.changePointerFocus(0, 0, 0)
A internal/core/border.go +183 -0
@@ -0,0 +1,183 @@
1+package core
2+
3+type borderKind int
4+
5+const (
6+ borderColumn borderKind = iota + 1
7+ borderRow
8+)
9+
10+// border is an output-local hit zone between consecutive columns or rows.
11+type border struct {
12+ kind borderKind
13+ col, row int
14+ rect Rect
15+}
16+
17+// borderMinWidth is the minimum logical pixel width for a border resize.
18+// The effective minimum also includes 5% of the lane and four border widths.
19+const borderMinWidth = 48
20+
21+func borderZone(a, b, pad int) (int, int) {
22+ mid := (a + b) / 2
23+ lo, hi := min(a, mid-pad), max(b, mid+pad)
24+ return lo, max(hi-lo, 1)
25+}
26+
27+func (w *Workspace) borderAt(x, y, pad int) (border, bool) {
28+ if w.fullscreen != 0 || !contains(w.Usable, x, y) {
29+ return border{}, false
30+ }
31+ // Overview and preview are Monitor presentation, not Workspace state. Their
32+ // caller must not ask for workspace borders while presenting those scenes.
33+ for _, p := range w.Layout() {
34+ if p.Floating && !p.Hidden && !p.Below && contains(p.Rect, x, y) {
35+ return border{}, false
36+ }
37+ }
38+ rs := w.columnRects()
39+ fixedFull := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
40+ for i, r := range rs {
41+ if fixedFull && i != w.Focus {
42+ continue
43+ }
44+ if r.X+r.W <= w.Usable.X || r.X >= w.Usable.X+w.Usable.W || r.Y+r.H <= w.Usable.Y || r.Y >= w.Usable.Y+w.Usable.H {
45+ continue
46+ }
47+ rows := rowRects(r, w.Columns[i], w.gap())
48+ for j := 0; j+1 < len(rows); j++ {
49+ lo, size := borderZone(rows[j].Y+rows[j].H, rows[j+1].Y, pad)
50+ b := border{kind: borderRow, col: i, row: j, rect: Rect{X: r.X, Y: lo, W: r.W, H: size}}
51+ if contains(b.rect, x, y) {
52+ return b, true
53+ }
54+ }
55+ if fixedFull || w.Overflow == OverflowFixed && !w.laneRow() || i+1 >= len(rs) || w.laneOf(i) != w.laneOf(i+1) || w.Columns[i].FullWidth || w.Columns[i+1].FullWidth {
56+ continue
57+ }
58+ next := rs[i+1]
59+ if next.X >= w.Usable.X+w.Usable.W || next.X+next.W <= w.Usable.X {
60+ continue
61+ }
62+ top, bottom := max(r.Y, next.Y), min(r.Y+r.H, next.Y+next.H)
63+ lo, size := borderZone(r.X+r.W, next.X, pad)
64+ b := border{kind: borderColumn, col: i, rect: Rect{X: lo, Y: top, W: size, H: max(bottom-top, 0)}}
65+ if contains(b.rect, x, y) {
66+ return b, true
67+ }
68+ }
69+ return border{}, false
70+}
71+
72+// pixelShare rounds upward so a stamped width reproduces its integer pixels.
73+func pixelShare(pixels, total int) int {
74+ if total <= 0 {
75+ return 0
76+ }
77+ return (pixels*10000 + total - 1) / total
78+}
79+
80+func (w *Workspace) setBorder(b border, pos int) {
81+ if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 {
82+ return
83+ }
84+ rs := w.columnRects()
85+ r := rs[b.col]
86+ if b.kind == borderRow {
87+ c := &w.Columns[b.col]
88+ if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 {
89+ return
90+ }
91+ rows := rowRects(r, *c, w.gap())
92+ avail := max(r.H-(len(rows)-1)*w.gap(), 0)
93+ if avail == 0 {
94+ return
95+ }
96+ if !validShares(*c) {
97+ c.Shares = make([]int, len(rows))
98+ used := 0
99+ for i := range rows {
100+ // Stamp the middle of the pixel bucket, leaving room for
101+ // pair-preserving rounding on subsequent resizes.
102+ c.Shares[i] = (rows[i].H*10000 + 5000) / avail
103+ used += c.Shares[i]
104+ }
105+ c.Shares[len(rows)-1] += 10000 - used
106+ }
107+ pair := c.Shares[b.row] + c.Shares[b.row+1]
108+ if pair < 2*minShare {
109+ return
110+ }
111+ target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)
112+ target = min(max(target, minShare), pair-minShare)
113+ if b.row+1 < len(rows)-1 {
114+ target = pairPixelShare(target, pair, avail, rows[b.row].H+rows[b.row+1].H, minShare)
115+ }
116+ c.Shares[b.row], c.Shares[b.row+1] = target, pair-target
117+ return
118+ }
119+ if b.kind != borderColumn || b.col+1 >= len(w.Columns) || w.laneOf(b.col) != w.laneOf(b.col+1) || w.Overflow == OverflowFixed && !w.laneRow() || w.Columns[b.col].FullWidth || w.Columns[b.col+1].FullWidth {
120+ return
121+ }
122+ first, last := w.laneBounds(w.laneOf(b.col))
123+ avail := max(w.Usable.W-2*w.gap(), 0)
124+ if w.policy().equalCells {
125+ avail = max(w.Usable.W-w.gap()*(last-first+2), 0)
126+ }
127+ if avail == 0 {
128+ return
129+ }
130+ if !w.validSpans(first, last) {
131+ w.spanSeq++
132+ used := 0
133+ for i := first; i <= last; i++ {
134+ w.Columns[i].Span = max((w.columnWidthFor(i, true)*10000+5000)/avail, 1)
135+ w.Columns[i].spanStamp = w.spanSeq
136+ used += w.Columns[i].Span
137+ }
138+ if w.policy().equalCells {
139+ w.Columns[last].Span += 10000 - used
140+ }
141+ }
142+ minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail)
143+ target := pixelShare(pos-w.gap()/2-r.X, avail)
144+ if !w.policy().equalCells {
145+ w.Columns[b.col].Span = min(max(target, min(minimum, 10000)), 10000)
146+ return
147+ }
148+ pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span
149+ if pair < 2*minimum {
150+ return
151+ }
152+ target = min(max(target, minimum), pair-minimum)
153+ if b.col+1 < last {
154+ target = pairPixelShare(target, pair, avail, rs[b.col].W+rs[b.col+1].W, minimum)
155+ }
156+ w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target
157+}
158+
159+func (w *Workspace) resetBorder(b border) {
160+ if b.col < 0 || b.col >= len(w.Columns) {
161+ return
162+ }
163+ switch b.kind {
164+ case borderColumn:
165+ w.clearSpans(b.col)
166+ case borderRow:
167+ w.Columns[b.col].Shares = nil
168+ }
169+}
170+
171+// pairPixelShare keeps the pair's combined integer width unchanged as well as
172+// its basis-point sum. Independent floors can otherwise shift later tiles by
173+// one pixel even when the sum of the two shares is constant.
174+func pairPixelShare(target, pair, avail, pixels, minimum int) int {
175+ left := avail * target / 10000
176+ right := pixels - left
177+ lo := max(pixelShare(left, avail), pair-pixelShare(right+1, avail)+1, minimum)
178+ hi := min(pixelShare(left+1, avail)-1, pair-pixelShare(right, avail), pair-minimum)
179+ if lo <= hi {
180+ return min(max(target, lo), hi)
181+ }
182+ return target
183+}
@@ -0,0 +1,183 @@
1+package core
2+
3+type borderKind int
4+
5+const (
6+ borderColumn borderKind = iota + 1
7+ borderRow
8+)
9+
10+// border is an output-local hit zone between consecutive columns or rows.
11+type border struct {
12+ kind borderKind
13+ col, row int
14+ rect Rect
15+}
16+
17+// borderMinWidth is the minimum logical pixel width for a border resize.
18+// The effective minimum also includes 5% of the lane and four border widths.
19+const borderMinWidth = 48
20+
21+func borderZone(a, b, pad int) (int, int) {
22+ mid := (a + b) / 2
23+ lo, hi := min(a, mid-pad), max(b, mid+pad)
24+ return lo, max(hi-lo, 1)
25+}
26+
27+func (w *Workspace) borderAt(x, y, pad int) (border, bool) {
28+ if w.fullscreen != 0 || !contains(w.Usable, x, y) {
29+ return border{}, false
30+ }
31+ // Overview and preview are Monitor presentation, not Workspace state. Their
32+ // caller must not ask for workspace borders while presenting those scenes.
33+ for _, p := range w.Layout() {
34+ if p.Floating && !p.Hidden && !p.Below && contains(p.Rect, x, y) {
35+ return border{}, false
36+ }
37+ }
38+ rs := w.columnRects()
39+ fixedFull := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
40+ for i, r := range rs {
41+ if fixedFull && i != w.Focus {
42+ continue
43+ }
44+ if r.X+r.W <= w.Usable.X || r.X >= w.Usable.X+w.Usable.W || r.Y+r.H <= w.Usable.Y || r.Y >= w.Usable.Y+w.Usable.H {
45+ continue
46+ }
47+ rows := rowRects(r, w.Columns[i], w.gap())
48+ for j := 0; j+1 < len(rows); j++ {
49+ lo, size := borderZone(rows[j].Y+rows[j].H, rows[j+1].Y, pad)
50+ b := border{kind: borderRow, col: i, row: j, rect: Rect{X: r.X, Y: lo, W: r.W, H: size}}
51+ if contains(b.rect, x, y) {
52+ return b, true
53+ }
54+ }
55+ if fixedFull || w.Overflow == OverflowFixed && !w.laneRow() || i+1 >= len(rs) || w.laneOf(i) != w.laneOf(i+1) || w.Columns[i].FullWidth || w.Columns[i+1].FullWidth {
56+ continue
57+ }
58+ next := rs[i+1]
59+ if next.X >= w.Usable.X+w.Usable.W || next.X+next.W <= w.Usable.X {
60+ continue
61+ }
62+ top, bottom := max(r.Y, next.Y), min(r.Y+r.H, next.Y+next.H)
63+ lo, size := borderZone(r.X+r.W, next.X, pad)
64+ b := border{kind: borderColumn, col: i, rect: Rect{X: lo, Y: top, W: size, H: max(bottom-top, 0)}}
65+ if contains(b.rect, x, y) {
66+ return b, true
67+ }
68+ }
69+ return border{}, false
70+}
71+
72+// pixelShare rounds upward so a stamped width reproduces its integer pixels.
73+func pixelShare(pixels, total int) int {
74+ if total <= 0 {
75+ return 0
76+ }
77+ return (pixels*10000 + total - 1) / total
78+}
79+
80+func (w *Workspace) setBorder(b border, pos int) {
81+ if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 {
82+ return
83+ }
84+ rs := w.columnRects()
85+ r := rs[b.col]
86+ if b.kind == borderRow {
87+ c := &w.Columns[b.col]
88+ if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 {
89+ return
90+ }
91+ rows := rowRects(r, *c, w.gap())
92+ avail := max(r.H-(len(rows)-1)*w.gap(), 0)
93+ if avail == 0 {
94+ return
95+ }
96+ if !validShares(*c) {
97+ c.Shares = make([]int, len(rows))
98+ used := 0
99+ for i := range rows {
100+ // Stamp the middle of the pixel bucket, leaving room for
101+ // pair-preserving rounding on subsequent resizes.
102+ c.Shares[i] = (rows[i].H*10000 + 5000) / avail
103+ used += c.Shares[i]
104+ }
105+ c.Shares[len(rows)-1] += 10000 - used
106+ }
107+ pair := c.Shares[b.row] + c.Shares[b.row+1]
108+ if pair < 2*minShare {
109+ return
110+ }
111+ target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)
112+ target = min(max(target, minShare), pair-minShare)
113+ if b.row+1 < len(rows)-1 {
114+ target = pairPixelShare(target, pair, avail, rows[b.row].H+rows[b.row+1].H, minShare)
115+ }
116+ c.Shares[b.row], c.Shares[b.row+1] = target, pair-target
117+ return
118+ }
119+ if b.kind != borderColumn || b.col+1 >= len(w.Columns) || w.laneOf(b.col) != w.laneOf(b.col+1) || w.Overflow == OverflowFixed && !w.laneRow() || w.Columns[b.col].FullWidth || w.Columns[b.col+1].FullWidth {
120+ return
121+ }
122+ first, last := w.laneBounds(w.laneOf(b.col))
123+ avail := max(w.Usable.W-2*w.gap(), 0)
124+ if w.policy().equalCells {
125+ avail = max(w.Usable.W-w.gap()*(last-first+2), 0)
126+ }
127+ if avail == 0 {
128+ return
129+ }
130+ if !w.validSpans(first, last) {
131+ w.spanSeq++
132+ used := 0
133+ for i := first; i <= last; i++ {
134+ w.Columns[i].Span = max((w.columnWidthFor(i, true)*10000+5000)/avail, 1)
135+ w.Columns[i].spanStamp = w.spanSeq
136+ used += w.Columns[i].Span
137+ }
138+ if w.policy().equalCells {
139+ w.Columns[last].Span += 10000 - used
140+ }
141+ }
142+ minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail)
143+ target := pixelShare(pos-w.gap()/2-r.X, avail)
144+ if !w.policy().equalCells {
145+ w.Columns[b.col].Span = min(max(target, min(minimum, 10000)), 10000)
146+ return
147+ }
148+ pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span
149+ if pair < 2*minimum {
150+ return
151+ }
152+ target = min(max(target, minimum), pair-minimum)
153+ if b.col+1 < last {
154+ target = pairPixelShare(target, pair, avail, rs[b.col].W+rs[b.col+1].W, minimum)
155+ }
156+ w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target
157+}
158+
159+func (w *Workspace) resetBorder(b border) {
160+ if b.col < 0 || b.col >= len(w.Columns) {
161+ return
162+ }
163+ switch b.kind {
164+ case borderColumn:
165+ w.clearSpans(b.col)
166+ case borderRow:
167+ w.Columns[b.col].Shares = nil
168+ }
169+}
170+
171+// pairPixelShare keeps the pair's combined integer width unchanged as well as
172+// its basis-point sum. Independent floors can otherwise shift later tiles by
173+// one pixel even when the sum of the two shares is constant.
174+func pairPixelShare(target, pair, avail, pixels, minimum int) int {
175+ left := avail * target / 10000
176+ right := pixels - left
177+ lo := max(pixelShare(left, avail), pair-pixelShare(right+1, avail)+1, minimum)
178+ hi := min(pixelShare(left+1, avail)-1, pair-pixelShare(right, avail), pair-minimum)
179+ if lo <= hi {
180+ return min(max(target, lo), hi)
181+ }
182+ return target
183+}
A internal/core/border_resize.go +89 -0
@@ -0,0 +1,89 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "time"
7+)
8+
9+// borderResize retains the original border identity, never the hit zone that
10+// moves as its neighbouring tiles resize. saved owns its slices.
11+type borderResize struct {
12+ core *Core
13+ screen *screen
14+ ws *Workspace
15+ border border
16+ btn uint32
17+ saved []Column
18+}
19+
20+func newBorderResize(c *Core, s *screen, w *Workspace, b border, button uint32) *borderResize {
21+ g := &borderResize{core: c, screen: s, ws: w, border: b, btn: button, saved: slices.Clone(w.Columns)}
22+ for i := range g.saved {
23+ g.saved[i].Windows = slices.Clone(g.saved[i].Windows)
24+ g.saved[i].Shares = slices.Clone(g.saved[i].Shares)
25+ }
26+ c.focusScreen = c.screenIndex(s.name())
27+ return g
28+}
29+
30+func (g *borderResize) button() uint32 { return g.btn }
31+func (g *borderResize) cursor() string {
32+ if g.border.kind == borderColumn {
33+ return "ew-resize"
34+ }
35+ return "ns-resize"
36+}
37+
38+func (g *borderResize) valid() bool {
39+ c, s, w := g.core, g.screen, g.ws
40+ if !slices.Contains(c.screens, s) || s.mon.Current() != w || s.mon.previewing() || s.mon.ov.open || w.pinned() || w.fullscreen != 0 || len(w.Columns) != len(g.saved) {
41+ return false
42+ }
43+ for i, col := range w.Columns {
44+ if !slices.Equal(col.Windows, g.saved[i].Windows) {
45+ return false
46+ }
47+ }
48+ b := g.border
49+ if b.col < 0 || b.col >= len(w.Columns) {
50+ return false
51+ }
52+ if b.kind == borderRow {
53+ return b.row >= 0 && b.row+1 < len(w.Columns[b.col].Windows)
54+ }
55+ return b.col+1 < len(w.Columns) && w.laneOf(b.col) == w.laneOf(b.col+1)
56+}
57+
58+func (g *borderResize) motion(ctx context.Context, _ time.Duration) error {
59+ c := g.core
60+ if !g.valid() {
61+ if err := c.abortPointerGrab(ctx); err != nil {
62+ return err
63+ }
64+ return c.publish(ctx)
65+ }
66+ pos := int(c.cursorX) - g.screen.x
67+ if g.border.kind == borderRow {
68+ pos = int(c.cursorY) - g.screen.y
69+ }
70+ g.ws.setBorder(g.border, pos)
71+ return c.publish(ctx)
72+}
73+
74+func (g *borderResize) release(ctx context.Context) error {
75+ if err := g.core.publish(ctx); err != nil {
76+ return err
77+ }
78+ return g.core.rehit(ctx)
79+}
80+
81+func (g *borderResize) cancel() {
82+ if !g.valid() {
83+ return
84+ }
85+ for i, saved := range g.saved {
86+ col := &g.ws.Columns[i]
87+ col.Span, col.spanStamp, col.Shares = saved.Span, saved.spanStamp, slices.Clone(saved.Shares)
88+ }
89+}
@@ -0,0 +1,89 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "time"
7+)
8+
9+// borderResize retains the original border identity, never the hit zone that
10+// moves as its neighbouring tiles resize. saved owns its slices.
11+type borderResize struct {
12+ core *Core
13+ screen *screen
14+ ws *Workspace
15+ border border
16+ btn uint32
17+ saved []Column
18+}
19+
20+func newBorderResize(c *Core, s *screen, w *Workspace, b border, button uint32) *borderResize {
21+ g := &borderResize{core: c, screen: s, ws: w, border: b, btn: button, saved: slices.Clone(w.Columns)}
22+ for i := range g.saved {
23+ g.saved[i].Windows = slices.Clone(g.saved[i].Windows)
24+ g.saved[i].Shares = slices.Clone(g.saved[i].Shares)
25+ }
26+ c.focusScreen = c.screenIndex(s.name())
27+ return g
28+}
29+
30+func (g *borderResize) button() uint32 { return g.btn }
31+func (g *borderResize) cursor() string {
32+ if g.border.kind == borderColumn {
33+ return "ew-resize"
34+ }
35+ return "ns-resize"
36+}
37+
38+func (g *borderResize) valid() bool {
39+ c, s, w := g.core, g.screen, g.ws
40+ if !slices.Contains(c.screens, s) || s.mon.Current() != w || s.mon.previewing() || s.mon.ov.open || w.pinned() || w.fullscreen != 0 || len(w.Columns) != len(g.saved) {
41+ return false
42+ }
43+ for i, col := range w.Columns {
44+ if !slices.Equal(col.Windows, g.saved[i].Windows) {
45+ return false
46+ }
47+ }
48+ b := g.border
49+ if b.col < 0 || b.col >= len(w.Columns) {
50+ return false
51+ }
52+ if b.kind == borderRow {
53+ return b.row >= 0 && b.row+1 < len(w.Columns[b.col].Windows)
54+ }
55+ return b.col+1 < len(w.Columns) && w.laneOf(b.col) == w.laneOf(b.col+1)
56+}
57+
58+func (g *borderResize) motion(ctx context.Context, _ time.Duration) error {
59+ c := g.core
60+ if !g.valid() {
61+ if err := c.abortPointerGrab(ctx); err != nil {
62+ return err
63+ }
64+ return c.publish(ctx)
65+ }
66+ pos := int(c.cursorX) - g.screen.x
67+ if g.border.kind == borderRow {
68+ pos = int(c.cursorY) - g.screen.y
69+ }
70+ g.ws.setBorder(g.border, pos)
71+ return c.publish(ctx)
72+}
73+
74+func (g *borderResize) release(ctx context.Context) error {
75+ if err := g.core.publish(ctx); err != nil {
76+ return err
77+ }
78+ return g.core.rehit(ctx)
79+}
80+
81+func (g *borderResize) cancel() {
82+ if !g.valid() {
83+ return
84+ }
85+ for i, saved := range g.saved {
86+ col := &g.ws.Columns[i]
87+ col.Span, col.spanStamp, col.Shares = saved.Span, saved.spanStamp, slices.Clone(saved.Shares)
88+ }
89+}
A internal/core/border_test.go +230 -0
@@ -0,0 +1,230 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func TestBorderAt(t *testing.T) {
9+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
10+ for _, gap := range []int{0, 10} {
11+ w := spanWorkspace(mode, 2)
12+ w.SetGaps(gap)
13+ w.View = 0
14+ w.view = slide{}
15+ rs := w.columnRects()
16+ x := rs[0].X + rs[0].W + gap/2
17+ y := rs[0].Y + 100
18+ b, ok := w.borderAt(x, y, 6)
19+ if !ok || b.kind != borderColumn || b.col != 0 {
20+ t.Fatal(mode, gap, b, ok)
21+ }
22+ if _, ok = w.borderAt(x-5, y, 6); !ok {
23+ t.Fatal("padding")
24+ }
25+ if _, ok = w.borderAt(rs[0].X+20, y, 6); ok {
26+ t.Fatal("tile interior")
27+ }
28+ w.Columns[0].Windows = append(w.Columns[0].Windows, 9)
29+ rows := rowRects(rs[0], w.Columns[0], gap)
30+ b, ok = w.borderAt(rs[0].X+20, rows[0].Y+rows[0].H+gap/2, 6)
31+ if !ok || b.kind != borderRow {
32+ t.Fatal("row", b, ok)
33+ }
34+ w.AddFloating(10, 400, 400)
35+ f := w.floatRect(w.Floats[0])
36+ if _, ok = w.borderAt(f.X+f.W/2, f.Y+f.H/2, 6); ok {
37+ t.Fatal("float")
38+ }
39+ w.FocusID(1)
40+ w.SetFullscreen(1, true)
41+ if _, ok = w.borderAt(x, y, 6); ok {
42+ t.Fatal("fullscreen")
43+ }
44+ }
45+ }
46+ w := spanWorkspace(OverflowFixed, 4)
47+ rs := w.columnRects()
48+ if _, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 8); ok {
49+ t.Fatal("spiral")
50+ }
51+ w = spanWorkspace(OverflowFixed, 2)
52+ w.Columns[w.Focus].FullWidth = true
53+ if _, ok := w.borderAt(500, 100, 8); ok {
54+ t.Fatal("maximized")
55+ }
56+ w = spanWorkspace(OverflowCascade, 4)
57+ rs = w.columnRects()
58+ if _, ok := w.borderAt(rs[2].X+rs[2].W+5, 100, 8); ok {
59+ t.Fatal("different bands")
60+ }
61+ // Presentation offsets must be applied just like Layout.
62+ w = spanWorkspace(OverflowScroll, 4)
63+ w.View = 100
64+ rs = w.columnRects()
65+ b, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 6)
66+ if !ok || b.col != 0 {
67+ t.Fatal("view offset", b, ok)
68+ }
69+}
70+
71+func TestSetColumnBorder(t *testing.T) {
72+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
73+ for _, n := range []int{2, 3} {
74+ w := spanWorkspace(mode, n)
75+ before := w.columnRects()
76+ b := border{kind: borderColumn, col: 0}
77+ pos := before[0].X + before[0].W + 5 + 50
78+ w.setBorder(b, pos)
79+ after := w.columnRects()
80+ if after[0].X+after[0].W+5 != pos {
81+ t.Fatal("position", mode, n, pos, after)
82+ }
83+ if n == 3 && after[2] != before[2] {
84+ t.Fatal("third moved", before, after)
85+ }
86+ if after[n-1].X+after[n-1].W != 990 {
87+ t.Fatal("not pixel filled", after)
88+ }
89+ total := 0
90+ for _, c := range w.Columns {
91+ total += c.Span
92+ }
93+ if total != 10000 {
94+ t.Fatal(total)
95+ }
96+ pair := w.Columns[0].Span + w.Columns[1].Span
97+ w.setBorder(b, -1000)
98+ after = w.columnRects()
99+ if after[0].W < 48 || w.Columns[0].Span+w.Columns[1].Span != pair {
100+ t.Fatal("minimum", after)
101+ }
102+ w.setBorder(b, 10000)
103+ after = w.columnRects()
104+ if after[1].W < 48 {
105+ t.Fatal("right minimum", after)
106+ }
107+ w.resetBorder(b)
108+ for _, c := range w.Columns {
109+ if c.Span != 0 || c.spanStamp != 0 {
110+ t.Fatal("reset")
111+ }
112+ }
113+ }
114+ }
115+}
116+
117+func TestSetBorderCascadeBand(t *testing.T) {
118+ w := spanWorkspace(OverflowCascade, 5)
119+ before := w.columnRects()
120+ w.setBorder(border{kind: borderColumn, col: 0}, 400)
121+ after := w.columnRects()
122+ if !slices.Equal(before[3:], after[3:]) || w.Columns[3].Span != 0 {
123+ t.Fatal("other band changed", before, after)
124+ }
125+}
126+
127+func TestSetBorderScroll(t *testing.T) {
128+ w := spanWorkspace(OverflowScroll, 3)
129+ before := w.columnRects()
130+ b := border{kind: borderColumn, col: 0}
131+ pos := before[0].X + before[0].W + 5 + 60
132+ w.setBorder(b, pos)
133+ after := w.columnRects()
134+ if after[0].X+after[0].W+5 != pos {
135+ t.Fatal("position", after)
136+ }
137+ for i := 1; i < len(after); i++ {
138+ if after[i].W != before[i].W || after[i].X != before[i].X+60 {
139+ t.Fatal("right columns", before, after)
140+ }
141+ }
142+ saved := w.Columns[1].Span
143+ w.setBorder(b, 10000)
144+ if w.columnRects()[0].W != 980 || w.Columns[1].Span != saved {
145+ t.Fatal("maximum")
146+ }
147+ w.setBorder(b, -10000)
148+ if w.columnRects()[0].W < 49 {
149+ t.Fatal("minimum")
150+ }
151+}
152+
153+func TestSetRowBorder(t *testing.T) {
154+ w := spanWorkspace(OverflowFixed, 2)
155+ w.Columns[0].Windows = []WindowID{1, 3, 4}
156+ r := w.columnRects()[0]
157+ before := rowRects(r, w.Columns[0], w.gap())
158+ b := border{kind: borderRow, col: 0, row: 0}
159+ pos := before[0].Y + before[0].H + 5 + 30
160+ w.setBorder(b, pos)
161+ after := rowRects(r, w.Columns[0], w.gap())
162+ if after[0].Y+after[0].H+5 != pos || after[2] != before[2] || sum(w.Columns[0].Shares) != 10000 {
163+ t.Fatal(before, after, w.Columns[0].Shares)
164+ }
165+ pair := w.Columns[0].Shares[0] + w.Columns[0].Shares[1]
166+ w.setBorder(b, -10000)
167+ if w.Columns[0].Shares[0] != minShare {
168+ t.Fatal("minimum")
169+ }
170+ w.setBorder(b, 10000)
171+ if w.Columns[0].Shares[1] != minShare || w.Columns[0].Shares[0]+w.Columns[0].Shares[1] != pair {
172+ t.Fatal("pair")
173+ }
174+ w.resetBorder(b)
175+ if w.Columns[0].Shares != nil {
176+ t.Fatal("reset")
177+ }
178+}
179+
180+func TestBorderPixelRemainders(t *testing.T) {
181+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
182+ for n := 2; n <= 4; n++ {
183+ for width := 997; width <= 1003; width++ {
184+ for pair := 0; pair < n-1; pair++ {
185+ w := spanWorkspace(mode, n)
186+ w.SetMaxColumns(n)
187+ for i := range w.Columns {
188+ w.Columns[i].BandStart = false
189+ }
190+ w.SetOutput(width, 800)
191+ before := w.columnRects()
192+ b := border{kind: borderColumn, col: pair}
193+ pos := before[pair].X + before[pair].W + 5
194+ w.setBorder(b, pos)
195+ if after := w.columnRects(); !slices.Equal(before, after) {
196+ t.Fatalf("stamp changed geometry: mode=%v n=%d width=%d pair=%d before=%v after=%v", mode, n, width, pair, before, after)
197+ }
198+ for delta := -20; delta <= 20; delta++ {
199+ w.setBorder(b, pos+delta)
200+ after := w.columnRects()
201+ for i := range before {
202+ if i != pair && i != pair+1 && after[i] != before[i] {
203+ t.Fatalf("untouched rect changed: mode=%v n=%d width=%d pair=%d delta=%d before=%v after=%v", mode, n, width, pair, delta, before, after)
204+ }
205+ }
206+ if after[pair].X+after[pair].W+5 != pos+delta {
207+ t.Fatal("border missed pointer", before, after)
208+ }
209+ }
210+ }
211+ }
212+ }
213+ }
214+}
215+
216+func TestBorderStampIgnoresMaximizedWidth(t *testing.T) {
217+ for _, mode := range []Overflow{OverflowScroll, OverflowFixed, OverflowCascade} {
218+ t.Run(string(mode), func(t *testing.T) {
219+ w := spanWorkspace(mode, 3)
220+ normal := w.columnWidthFor(2, true)
221+ w.Columns[2].FullWidth = true
222+ rs := w.columnRects()
223+ w.setBorder(border{kind: borderColumn, col: 0}, rs[0].X+rs[0].W+15)
224+ w.Columns[2].FullWidth = false
225+ if got := w.columnWidth(2); got != normal {
226+ t.Fatalf("unmaximized width = %d, want %d", got, normal)
227+ }
228+ })
229+ }
230+}
@@ -0,0 +1,230 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func TestBorderAt(t *testing.T) {
9+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
10+ for _, gap := range []int{0, 10} {
11+ w := spanWorkspace(mode, 2)
12+ w.SetGaps(gap)
13+ w.View = 0
14+ w.view = slide{}
15+ rs := w.columnRects()
16+ x := rs[0].X + rs[0].W + gap/2
17+ y := rs[0].Y + 100
18+ b, ok := w.borderAt(x, y, 6)
19+ if !ok || b.kind != borderColumn || b.col != 0 {
20+ t.Fatal(mode, gap, b, ok)
21+ }
22+ if _, ok = w.borderAt(x-5, y, 6); !ok {
23+ t.Fatal("padding")
24+ }
25+ if _, ok = w.borderAt(rs[0].X+20, y, 6); ok {
26+ t.Fatal("tile interior")
27+ }
28+ w.Columns[0].Windows = append(w.Columns[0].Windows, 9)
29+ rows := rowRects(rs[0], w.Columns[0], gap)
30+ b, ok = w.borderAt(rs[0].X+20, rows[0].Y+rows[0].H+gap/2, 6)
31+ if !ok || b.kind != borderRow {
32+ t.Fatal("row", b, ok)
33+ }
34+ w.AddFloating(10, 400, 400)
35+ f := w.floatRect(w.Floats[0])
36+ if _, ok = w.borderAt(f.X+f.W/2, f.Y+f.H/2, 6); ok {
37+ t.Fatal("float")
38+ }
39+ w.FocusID(1)
40+ w.SetFullscreen(1, true)
41+ if _, ok = w.borderAt(x, y, 6); ok {
42+ t.Fatal("fullscreen")
43+ }
44+ }
45+ }
46+ w := spanWorkspace(OverflowFixed, 4)
47+ rs := w.columnRects()
48+ if _, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 8); ok {
49+ t.Fatal("spiral")
50+ }
51+ w = spanWorkspace(OverflowFixed, 2)
52+ w.Columns[w.Focus].FullWidth = true
53+ if _, ok := w.borderAt(500, 100, 8); ok {
54+ t.Fatal("maximized")
55+ }
56+ w = spanWorkspace(OverflowCascade, 4)
57+ rs = w.columnRects()
58+ if _, ok := w.borderAt(rs[2].X+rs[2].W+5, 100, 8); ok {
59+ t.Fatal("different bands")
60+ }
61+ // Presentation offsets must be applied just like Layout.
62+ w = spanWorkspace(OverflowScroll, 4)
63+ w.View = 100
64+ rs = w.columnRects()
65+ b, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 6)
66+ if !ok || b.col != 0 {
67+ t.Fatal("view offset", b, ok)
68+ }
69+}
70+
71+func TestSetColumnBorder(t *testing.T) {
72+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
73+ for _, n := range []int{2, 3} {
74+ w := spanWorkspace(mode, n)
75+ before := w.columnRects()
76+ b := border{kind: borderColumn, col: 0}
77+ pos := before[0].X + before[0].W + 5 + 50
78+ w.setBorder(b, pos)
79+ after := w.columnRects()
80+ if after[0].X+after[0].W+5 != pos {
81+ t.Fatal("position", mode, n, pos, after)
82+ }
83+ if n == 3 && after[2] != before[2] {
84+ t.Fatal("third moved", before, after)
85+ }
86+ if after[n-1].X+after[n-1].W != 990 {
87+ t.Fatal("not pixel filled", after)
88+ }
89+ total := 0
90+ for _, c := range w.Columns {
91+ total += c.Span
92+ }
93+ if total != 10000 {
94+ t.Fatal(total)
95+ }
96+ pair := w.Columns[0].Span + w.Columns[1].Span
97+ w.setBorder(b, -1000)
98+ after = w.columnRects()
99+ if after[0].W < 48 || w.Columns[0].Span+w.Columns[1].Span != pair {
100+ t.Fatal("minimum", after)
101+ }
102+ w.setBorder(b, 10000)
103+ after = w.columnRects()
104+ if after[1].W < 48 {
105+ t.Fatal("right minimum", after)
106+ }
107+ w.resetBorder(b)
108+ for _, c := range w.Columns {
109+ if c.Span != 0 || c.spanStamp != 0 {
110+ t.Fatal("reset")
111+ }
112+ }
113+ }
114+ }
115+}
116+
117+func TestSetBorderCascadeBand(t *testing.T) {
118+ w := spanWorkspace(OverflowCascade, 5)
119+ before := w.columnRects()
120+ w.setBorder(border{kind: borderColumn, col: 0}, 400)
121+ after := w.columnRects()
122+ if !slices.Equal(before[3:], after[3:]) || w.Columns[3].Span != 0 {
123+ t.Fatal("other band changed", before, after)
124+ }
125+}
126+
127+func TestSetBorderScroll(t *testing.T) {
128+ w := spanWorkspace(OverflowScroll, 3)
129+ before := w.columnRects()
130+ b := border{kind: borderColumn, col: 0}
131+ pos := before[0].X + before[0].W + 5 + 60
132+ w.setBorder(b, pos)
133+ after := w.columnRects()
134+ if after[0].X+after[0].W+5 != pos {
135+ t.Fatal("position", after)
136+ }
137+ for i := 1; i < len(after); i++ {
138+ if after[i].W != before[i].W || after[i].X != before[i].X+60 {
139+ t.Fatal("right columns", before, after)
140+ }
141+ }
142+ saved := w.Columns[1].Span
143+ w.setBorder(b, 10000)
144+ if w.columnRects()[0].W != 980 || w.Columns[1].Span != saved {
145+ t.Fatal("maximum")
146+ }
147+ w.setBorder(b, -10000)
148+ if w.columnRects()[0].W < 49 {
149+ t.Fatal("minimum")
150+ }
151+}
152+
153+func TestSetRowBorder(t *testing.T) {
154+ w := spanWorkspace(OverflowFixed, 2)
155+ w.Columns[0].Windows = []WindowID{1, 3, 4}
156+ r := w.columnRects()[0]
157+ before := rowRects(r, w.Columns[0], w.gap())
158+ b := border{kind: borderRow, col: 0, row: 0}
159+ pos := before[0].Y + before[0].H + 5 + 30
160+ w.setBorder(b, pos)
161+ after := rowRects(r, w.Columns[0], w.gap())
162+ if after[0].Y+after[0].H+5 != pos || after[2] != before[2] || sum(w.Columns[0].Shares) != 10000 {
163+ t.Fatal(before, after, w.Columns[0].Shares)
164+ }
165+ pair := w.Columns[0].Shares[0] + w.Columns[0].Shares[1]
166+ w.setBorder(b, -10000)
167+ if w.Columns[0].Shares[0] != minShare {
168+ t.Fatal("minimum")
169+ }
170+ w.setBorder(b, 10000)
171+ if w.Columns[0].Shares[1] != minShare || w.Columns[0].Shares[0]+w.Columns[0].Shares[1] != pair {
172+ t.Fatal("pair")
173+ }
174+ w.resetBorder(b)
175+ if w.Columns[0].Shares != nil {
176+ t.Fatal("reset")
177+ }
178+}
179+
180+func TestBorderPixelRemainders(t *testing.T) {
181+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
182+ for n := 2; n <= 4; n++ {
183+ for width := 997; width <= 1003; width++ {
184+ for pair := 0; pair < n-1; pair++ {
185+ w := spanWorkspace(mode, n)
186+ w.SetMaxColumns(n)
187+ for i := range w.Columns {
188+ w.Columns[i].BandStart = false
189+ }
190+ w.SetOutput(width, 800)
191+ before := w.columnRects()
192+ b := border{kind: borderColumn, col: pair}
193+ pos := before[pair].X + before[pair].W + 5
194+ w.setBorder(b, pos)
195+ if after := w.columnRects(); !slices.Equal(before, after) {
196+ t.Fatalf("stamp changed geometry: mode=%v n=%d width=%d pair=%d before=%v after=%v", mode, n, width, pair, before, after)
197+ }
198+ for delta := -20; delta <= 20; delta++ {
199+ w.setBorder(b, pos+delta)
200+ after := w.columnRects()
201+ for i := range before {
202+ if i != pair && i != pair+1 && after[i] != before[i] {
203+ t.Fatalf("untouched rect changed: mode=%v n=%d width=%d pair=%d delta=%d before=%v after=%v", mode, n, width, pair, delta, before, after)
204+ }
205+ }
206+ if after[pair].X+after[pair].W+5 != pos+delta {
207+ t.Fatal("border missed pointer", before, after)
208+ }
209+ }
210+ }
211+ }
212+ }
213+ }
214+}
215+
216+func TestBorderStampIgnoresMaximizedWidth(t *testing.T) {
217+ for _, mode := range []Overflow{OverflowScroll, OverflowFixed, OverflowCascade} {
218+ t.Run(string(mode), func(t *testing.T) {
219+ w := spanWorkspace(mode, 3)
220+ normal := w.columnWidthFor(2, true)
221+ w.Columns[2].FullWidth = true
222+ rs := w.columnRects()
223+ w.setBorder(border{kind: borderColumn, col: 0}, rs[0].X+rs[0].W+15)
224+ w.Columns[2].FullWidth = false
225+ if got := w.columnWidth(2); got != normal {
226+ t.Fatalf("unmaximized width = %d, want %d", got, normal)
227+ }
228+ })
229+ }
230+}
M internal/core/cascade.go +26 -3
@@ -141,15 +141,36 @@ func (w *Workspace) laneWeight(i, S, u int) int {
141141
142142 // laneWidth is the width of column i of the lane first..last with the
143143 // stats of laneStats: the usable width after gaps, split by weight. The
144-// remainder under one pixel per weight stays empty, as in equalShare.
144+// last column receives the pixel remainder, just as for stamped spans.
145145 func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
146+ if w.validSpans(first, last) {
147+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
148+ if i == last {
149+ used := 0
150+ for j := first; j < last; j++ {
151+ used += avail * w.Columns[j].Span / 10000
152+ }
153+ return avail - used
154+ }
155+ return avail * w.Columns[i].Span / 10000
156+ }
146157 if sum <= 0 {
147158 return 0
148159 }
149160 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
150161 // floor(avail*weight/sum) without overflowing on a huge area.
151- weight := w.laneWeight(i, S, u)
152- return avail/sum*weight + avail%sum*weight/sum
162+ width := func(j int) int {
163+ weight := w.laneWeight(j, S, u)
164+ return avail/sum*weight + avail%sum*weight/sum
165+ }
166+ if i == last {
167+ used := 0
168+ for j := first; j < last; j++ {
169+ used += width(j)
170+ }
171+ return avail - used
172+ }
173+ return width(i)
153174 }
154175
155176 // equalShare is the width of each of k columns sharing the usable width
@@ -492,6 +513,8 @@ func (w *Workspace) spawnIndex() (at int, newBand bool) {
492513
493514 // deleteColumn removes column i; a band it started keeps its start.
494515 func (w *Workspace) deleteColumn(i int) {
516+ // A removed member cannot be detected by counting surviving stamp members.
517+ w.clearSpans(i)
495518 if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
496519 w.Columns[i+1].BandStart = true
497520 }
@@ -141,15 +141,36 @@ func (w *Workspace) laneWeight(i, S, u int) int {
141 141
142 // laneWidth is the width of column i of the lane first..last with the142 // laneWidth is the width of column i of the lane first..last with the
143 // stats of laneStats: the usable width after gaps, split by weight. The143 // stats of laneStats: the usable width after gaps, split by weight. The
144-// remainder under one pixel per weight stays empty, as in equalShare.144+// last column receives the pixel remainder, just as for stamped spans.
145 func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {145 func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
146+ if w.validSpans(first, last) {
147+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
148+ if i == last {
149+ used := 0
150+ for j := first; j < last; j++ {
151+ used += avail * w.Columns[j].Span / 10000
152+ }
153+ return avail - used
154+ }
155+ return avail * w.Columns[i].Span / 10000
156+ }
146 if sum <= 0 {157 if sum <= 0 {
147 return 0158 return 0
148 }159 }
149 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)160 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
150 // floor(avail*weight/sum) without overflowing on a huge area.161 // floor(avail*weight/sum) without overflowing on a huge area.
151- weight := w.laneWeight(i, S, u)162+ width := func(j int) int {
152- return avail/sum*weight + avail%sum*weight/sum163+ weight := w.laneWeight(j, S, u)
164+ return avail/sum*weight + avail%sum*weight/sum
165+ }
166+ if i == last {
167+ used := 0
168+ for j := first; j < last; j++ {
169+ used += width(j)
170+ }
171+ return avail - used
172+ }
173+ return width(i)
153 }174 }
154 175
155 // equalShare is the width of each of k columns sharing the usable width176 // equalShare is the width of each of k columns sharing the usable width
@@ -492,6 +513,8 @@ func (w *Workspace) spawnIndex() (at int, newBand bool) {
492 513
493 // deleteColumn removes column i; a band it started keeps its start.514 // deleteColumn removes column i; a band it started keeps its start.
494 func (w *Workspace) deleteColumn(i int) {515 func (w *Workspace) deleteColumn(i int) {
516+ // A removed member cannot be detected by counting surviving stamp members.
517+ w.clearSpans(i)
495 if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {518 if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
496 w.Columns[i+1].BandStart = true519 w.Columns[i+1].BandStart = true
497 }520 }
M internal/core/cascade_test.go +1 -1
@@ -49,7 +49,7 @@ func TestCascadePartialLastBandWithGaps(t *testing.T) {
4949 third, half := (1000-4*8)/3, (1000-3*8)/2
5050 rects := w.columnRectsFor(false)
5151 for i, want := range []struct{ x, w int }{
52- {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third},
52+ {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), 1000 - 4*8 - 2*third},
5353 {8, half}, {8 + half + 8, half},
5454 } {
5555 if r := rects[i]; r.X != want.x || r.W != want.w {
@@ -49,7 +49,7 @@ func TestCascadePartialLastBandWithGaps(t *testing.T) {
49 third, half := (1000-4*8)/3, (1000-3*8)/249 third, half := (1000-4*8)/3, (1000-3*8)/2
50 rects := w.columnRectsFor(false)50 rects := w.columnRectsFor(false)
51 for i, want := range []struct{ x, w int }{51 for i, want := range []struct{ x, w int }{
52- {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third},52+ {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), 1000 - 4*8 - 2*third},
53 {8, half}, {8 + half + 8, half},53 {8, half}, {8 + half + 8, half},
54 } {54 } {
55 if r := rects[i]; r.X != want.x || r.W != want.w {55 if r := rects[i]; r.X != want.x || r.W != want.w {
M internal/core/client.go +5 -0
@@ -136,6 +136,11 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
136136 return err
137137 }
138138 c.unmapWindow(v)
139+ if c.pgrab != nil && !c.pgrab.valid() {
140+ if err := c.abortPointerGrab(ctx); err != nil {
141+ return err
142+ }
143+ }
139144 c.releaseSlots()
140145 if c.pointer == v.ID {
141146 c.pointer = 0
@@ -136,6 +136,11 @@ func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
136 return err136 return err
137 }137 }
138 c.unmapWindow(v)138 c.unmapWindow(v)
139+ if c.pgrab != nil && !c.pgrab.valid() {
140+ if err := c.abortPointerGrab(ctx); err != nil {
141+ return err
142+ }
143+ }
139 c.releaseSlots()144 c.releaseSlots()
140 if c.pointer == v.ID {145 if c.pointer == v.ID {
141 c.pointer = 0146 c.pointer = 0
M internal/core/clientgesture.go +1 -1
@@ -38,7 +38,7 @@ func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers
3838 return err
3939 }
4040 c.gesture = clientGesture{on: true, kind: kind, at: at}
41- if c.pointer == 0 || c.drag != nil || c.dataDrag != nil {
41+ if c.pointer == 0 || c.pointerBusy() || c.dataDrag != nil {
4242 return nil
4343 }
4444 c.gesture.id = c.pointer
@@ -38,7 +38,7 @@ func (c *Core) gestureBegin(ctx context.Context, kind ports.GestureKind, fingers
38 return err38 return err
39 }39 }
40 c.gesture = clientGesture{on: true, kind: kind, at: at}40 c.gesture = clientGesture{on: true, kind: kind, at: at}
41- if c.pointer == 0 || c.drag != nil || c.dataDrag != nil {41+ if c.pointer == 0 || c.pointerBusy() || c.dataDrag != nil {
42 return nil42 return nil
43 }43 }
44 c.gesture.id = c.pointer44 c.gesture.id = c.pointer
M internal/core/column_cells_test.go +2 -2
@@ -56,11 +56,11 @@ func TestGrowColumnCycle(t *testing.T) {
5656 }},
5757 {"cascade three columns", OverflowCascade, 4, 3, 2, []step{
5858 {[]int{1, 2, 1}, []int{200, 400, 200}},
59- {[]int{0, 0, 0}, []int{266, 266, 266}},
59+ {[]int{0, 0, 0}, []int{266, 266, 268}},
6060 }},
6161 {"fixed three columns", OverflowFixed, 4, 3, 2, []step{
6262 {[]int{1, 2, 1}, []int{200, 400, 200}},
63- {[]int{0, 0, 0}, []int{266, 266, 266}},
63+ {[]int{0, 0, 0}, []int{266, 266, 268}},
6464 }},
6565 {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{
6666 // 5 cells over 3 columns: 1 each and the 2 left to the focused one.
@@ -56,11 +56,11 @@ func TestGrowColumnCycle(t *testing.T) {
56 }},56 }},
57 {"cascade three columns", OverflowCascade, 4, 3, 2, []step{57 {"cascade three columns", OverflowCascade, 4, 3, 2, []step{
58 {[]int{1, 2, 1}, []int{200, 400, 200}},58 {[]int{1, 2, 1}, []int{200, 400, 200}},
59- {[]int{0, 0, 0}, []int{266, 266, 266}},59+ {[]int{0, 0, 0}, []int{266, 266, 268}},
60 }},60 }},
61 {"fixed three columns", OverflowFixed, 4, 3, 2, []step{61 {"fixed three columns", OverflowFixed, 4, 3, 2, []step{
62 {[]int{1, 2, 1}, []int{200, 400, 200}},62 {[]int{1, 2, 1}, []int{200, 400, 200}},
63- {[]int{0, 0, 0}, []int{266, 266, 266}},63+ {[]int{0, 0, 0}, []int{266, 266, 268}},
64 }},64 }},
65 {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{65 {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{
66 // 5 cells over 3 columns: 1 each and the 2 left to the focused one.66 // 5 cells over 3 columns: 1 each and the 2 left to the focused one.
M internal/core/core.go +13 -2
@@ -110,7 +110,13 @@ type Core struct {
110110 // buttons whose release no client must see. mods are the modifiers
111111 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
112112 // press a client saw, the one xdg_toplevel.move's serial names.
113- drag *dragState
113+ drag *dragState
114+ pgrab pointerGrab
115+ lastBorderClick struct {
116+ at time.Duration
117+ ws *Workspace
118+ b border
119+ }
114120 // dataDrag is the client data drag in progress (datadrag.go).
115121 dataDrag *dataDragState
116122 swallow map[uint32]bool
@@ -633,6 +639,11 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
633639 sc.mon.Current().noteFocus()
634640 }
635641 }
642+ if c.pgrab != nil && !c.pgrab.valid() {
643+ if err := c.abortPointerGrab(ctx); err != nil {
644+ return err
645+ }
646+ }
636647 c.refreshShown()
637648 c.captureExpire()
638649 capture, err := c.captureEvaluate(ctx)
@@ -754,7 +765,7 @@ func (c *Core) rehit(ctx context.Context) error {
754765 // The drag target follows the layout too.
755766 return c.dataDragRetarget(ctx, c.motionTime)
756767 }
757- if c.grab != 0 || c.drag != nil {
768+ if c.grab != 0 || c.pointerBusy() {
758769 return nil
759770 }
760771 id, x, y := c.hit(c.cursorX, c.cursorY)
@@ -110,7 +110,13 @@ type Core struct {
110 // buttons whose release no client must see. mods are the modifiers110 // buttons whose release no client must see. mods are the modifiers
111 // held, cmdMod the one keyboard.cmd names. lastButton is the latest111 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
112 // press a client saw, the one xdg_toplevel.move's serial names.112 // press a client saw, the one xdg_toplevel.move's serial names.
113- drag *dragState113+ drag *dragState
114+ pgrab pointerGrab
115+ lastBorderClick struct {
116+ at time.Duration
117+ ws *Workspace
118+ b border
119+ }
114 // dataDrag is the client data drag in progress (datadrag.go).120 // dataDrag is the client data drag in progress (datadrag.go).
115 dataDrag *dataDragState121 dataDrag *dataDragState
116 swallow map[uint32]bool122 swallow map[uint32]bool
@@ -633,6 +639,11 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
633 sc.mon.Current().noteFocus()639 sc.mon.Current().noteFocus()
634 }640 }
635 }641 }
642+ if c.pgrab != nil && !c.pgrab.valid() {
643+ if err := c.abortPointerGrab(ctx); err != nil {
644+ return err
645+ }
646+ }
636 c.refreshShown()647 c.refreshShown()
637 c.captureExpire()648 c.captureExpire()
638 capture, err := c.captureEvaluate(ctx)649 capture, err := c.captureEvaluate(ctx)
@@ -754,7 +765,7 @@ func (c *Core) rehit(ctx context.Context) error {
754 // The drag target follows the layout too.765 // The drag target follows the layout too.
755 return c.dataDragRetarget(ctx, c.motionTime)766 return c.dataDragRetarget(ctx, c.motionTime)
756 }767 }
757- if c.grab != 0 || c.drag != nil {768+ if c.grab != 0 || c.pointerBusy() {
758 return nil769 return nil
759 }770 }
760 id, x, y := c.hit(c.cursorX, c.cursorY)771 id, x, y := c.hit(c.cursorX, c.cursorY)
M internal/core/datadrag.go +1 -1
@@ -50,7 +50,7 @@ type dataDragIcon struct {
5050 // during a window drag or a locked session is refused.
5151 func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {
5252 button := v.Button
53- if c.dataDrag != nil || c.drag != nil || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
53+ if c.dataDrag != nil || c.pointerBusy() || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
5454 return c.command(ctx, ports.DataDragCancel{})
5555 }
5656 c.dataDrag = &dataDragState{button: button, icon: dataDragIcon{id: v.Icon}}
@@ -50,7 +50,7 @@ type dataDragIcon struct {
50 // during a window drag or a locked session is refused.50 // during a window drag or a locked session is refused.
51 func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {51 func (c *Core) startDataDrag(ctx context.Context, v ports.DataDragStarted) error {
52 button := v.Button52 button := v.Button
53- if c.dataDrag != nil || c.drag != nil || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {53+ if c.dataDrag != nil || c.pointerBusy() || c.security.Protected || c.grab == 0 || c.held() == 0 || !c.buttons[button] || c.swallow[button] {
54 return c.command(ctx, ports.DataDragCancel{})54 return c.command(ctx, ports.DataDragCancel{})
55 }55 }
56 c.dataDrag = &dataDragState{button: button, icon: dataDragIcon{id: v.Icon}}56 c.dataDrag = &dataDragState{button: button, icon: dataDragIcon{id: v.Icon}}
M internal/core/hint.go +1 -1
@@ -127,7 +127,7 @@ func (c *Core) hintWindows(sc *screen) []ports.HintWindow {
127127 // drag, or the column switcher.
128128 func (c *Core) hintPointerBusy() bool {
129129 return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
130- c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()
130+ c.grab != 0 || len(c.buttons) > 0 || c.pointerBusy() || c.dataDrag != nil || c.switching()
131131 }
132132
133133 // hintResult handles a scanner result and reports whether to publish.
@@ -127,7 +127,7 @@ func (c *Core) hintWindows(sc *screen) []ports.HintWindow {
127 // drag, or the column switcher.127 // drag, or the column switcher.
128 func (c *Core) hintPointerBusy() bool {128 func (c *Core) hintPointerBusy() bool {
129 return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||129 return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
130- c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()130+ c.grab != 0 || len(c.buttons) > 0 || c.pointerBusy() || c.dataDrag != nil || c.switching()
131 }131 }
132 132
133 // hintResult handles a scanner result and reports whether to publish.133 // hintResult handles a scanner result and reports whether to publish.
M internal/core/input.go +25 -5
@@ -98,6 +98,9 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
9898 }
9999 }
100100 }
101+ if c.pgrab != nil {
102+ return c.pgrab.motion(ctx, v.Time)
103+ }
101104 if c.drag != nil {
102105 // The dragged window and the others get no pointer events.
103106 return c.dragMotion(ctx, v.Time)
@@ -150,6 +153,16 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
150153 if c.dataDrag != nil {
151154 return c.dataDragButton(ctx, v)
152155 }
156+ if g := c.pgrab; g != nil {
157+ if !v.Pressed && v.Button == g.button() {
158+ delete(c.buttons, v.Button)
159+ return c.endPointerGrab(ctx)
160+ }
161+ if v.Pressed {
162+ c.buttons[v.Button], c.swallow[v.Button] = true, true
163+ }
164+ return nil
165+ }
153166 if d := c.drag; d != nil {
154167 if !v.Pressed && v.Button == d.button {
155168 delete(c.buttons, v.Button)
@@ -167,7 +180,7 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
167180 return err
168181 }
169182 }
170- if started, err := c.startDrag(ctx, v); err != nil || started {
183+ if started, err := c.startPointerGrab(ctx, v); err != nil || started {
171184 return err
172185 }
173186 if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {
@@ -248,7 +261,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
248261 }
249262 // Scroll goes to the window under the pointer, which has the
250263 // pointer focus even mid-drag. None during a window drag.
251- if c.pointer != 0 && c.drag == nil && c.dataDrag == nil {
264+ if c.pointer != 0 && !c.pointerBusy() && c.dataDrag == nil {
252265 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
253266 return err
254267 }
@@ -278,9 +291,16 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
278291 c.pressed[heldKey(key)] = true
279292 return c.abortSwitcher(ctx)
280293 }
281- if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
282- // Escape cancels the drag; its release goes nowhere either.
283- c.cancelDrag()
294+ if c.pointerBusy() && key.Pressed && key.Keysym == "Escape" {
295+ // Escape cancels the grab; its release goes nowhere either.
296+ if c.pgrab != nil {
297+ c.pgrab.cancel()
298+ if err := c.abortPointerGrab(ctx); err != nil {
299+ return err
300+ }
301+ } else {
302+ c.cancelDrag()
303+ }
284304 c.pressed[heldKey(key)] = true
285305 if err := c.publish(ctx); err != nil {
286306 return err
@@ -98,6 +98,9 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
98 }98 }
99 }99 }
100 }100 }
101+ if c.pgrab != nil {
102+ return c.pgrab.motion(ctx, v.Time)
103+ }
101 if c.drag != nil {104 if c.drag != nil {
102 // The dragged window and the others get no pointer events.105 // The dragged window and the others get no pointer events.
103 return c.dragMotion(ctx, v.Time)106 return c.dragMotion(ctx, v.Time)
@@ -150,6 +153,16 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
150 if c.dataDrag != nil {153 if c.dataDrag != nil {
151 return c.dataDragButton(ctx, v)154 return c.dataDragButton(ctx, v)
152 }155 }
156+ if g := c.pgrab; g != nil {
157+ if !v.Pressed && v.Button == g.button() {
158+ delete(c.buttons, v.Button)
159+ return c.endPointerGrab(ctx)
160+ }
161+ if v.Pressed {
162+ c.buttons[v.Button], c.swallow[v.Button] = true, true
163+ }
164+ return nil
165+ }
153 if d := c.drag; d != nil {166 if d := c.drag; d != nil {
154 if !v.Pressed && v.Button == d.button {167 if !v.Pressed && v.Button == d.button {
155 delete(c.buttons, v.Button)168 delete(c.buttons, v.Button)
@@ -167,7 +180,7 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
167 return err180 return err
168 }181 }
169 }182 }
170- if started, err := c.startDrag(ctx, v); err != nil || started {183+ if started, err := c.startPointerGrab(ctx, v); err != nil || started {
171 return err184 return err
172 }185 }
173 if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {186 if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {
@@ -248,7 +261,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
248 }261 }
249 // Scroll goes to the window under the pointer, which has the262 // Scroll goes to the window under the pointer, which has the
250 // pointer focus even mid-drag. None during a window drag.263 // pointer focus even mid-drag. None during a window drag.
251- if c.pointer != 0 && c.drag == nil && c.dataDrag == nil {264+ if c.pointer != 0 && !c.pointerBusy() && c.dataDrag == nil {
252 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {265 if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
253 return err266 return err
254 }267 }
@@ -278,9 +291,16 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
278 c.pressed[heldKey(key)] = true291 c.pressed[heldKey(key)] = true
279 return c.abortSwitcher(ctx)292 return c.abortSwitcher(ctx)
280 }293 }
281- if c.drag != nil && key.Pressed && key.Keysym == "Escape" {294+ if c.pointerBusy() && key.Pressed && key.Keysym == "Escape" {
282- // Escape cancels the drag; its release goes nowhere either.295+ // Escape cancels the grab; its release goes nowhere either.
283- c.cancelDrag()296+ if c.pgrab != nil {
297+ c.pgrab.cancel()
298+ if err := c.abortPointerGrab(ctx); err != nil {
299+ return err
300+ }
301+ } else {
302+ c.cancelDrag()
303+ }
284 c.pressed[heldKey(key)] = true304 c.pressed[heldKey(key)] = true
285 if err := c.publish(ctx); err != nil {305 if err := c.publish(ctx); err != nil {
286 return err306 return err
M internal/core/layout.go +28 -12
@@ -19,6 +19,9 @@ const maxCells = 16
1919 // max-columns-th of the usable width. Zero (auto) shares the width equally
2020 // with the other auto columns, up to MaxColumns visible at once.
2121 type Column struct {
22+ // Span is a manually sized width in basis points; zero uses Cells.
23+ Span int
24+ spanStamp uint64
2225 Windows []WindowID
2326 Cells int
2427 FullWidth bool
@@ -26,9 +29,9 @@ type Column struct {
2629 // Slot is the declared column number (workspace.<name>.column.N) of a
2730 // slot window; 0 for normal columns.
2831 Slot int
29- // Shares are the row heights set by set-window-height, in percent, in
32+ // Shares are the row heights set by set-window-height, in basis points, in
3033 // Windows order. They apply only while there is one per window and
31- // they sum to 100; otherwise the rows share the height equally.
34+ // they sum to 10000; otherwise the rows share the height equally.
3235 Shares []int
3336 // BandStart marks the first column of a cascade band. It belongs to the
3437 // position, not the column: swaps leave it in place. See cascade.go.
@@ -105,6 +108,8 @@ type Workspace struct {
105108 overviewAfter *Workspace
106109 Overflow Overflow
107110 Columns []Column
111+ // spanSeq issues workspace-local identities for complete lane stamps.
112+ spanSeq uint64
108113 // maximized remembers recently maximized columns by window ID, newest
109114 // first; stack() filters removed windows at read time.
110115 maximized []WindowID
@@ -534,6 +539,7 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
534539 at = min(w.Focus+1, len(w.Columns))
535540 }
536541 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
542+ w.dropStaleSpans()
537543 return
538544 }
539545 w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
@@ -542,6 +548,8 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
542548 // addColumn places a new column like a new window and focuses it.
543549 func (w *Workspace) addColumn(col Column) {
544550 at := 0
551+ // Stamp identities belong to the issuing workspace and complete lane.
552+ col.Span, col.spanStamp = 0, 0
545553 col.BandStart = false
546554 if w.policy().wraps {
547555 // After the focused band: it joins it when it has room, else it
@@ -916,33 +924,33 @@ func (w *Workspace) MoveWindow(dir int) {
916924 w.scroll()
917925 }
918926
919-// minShare is the smallest row height set-window-height leaves, percent.
920-const minShare = 10
927+// minShare is the smallest row height set-window-height leaves, in basis points.
928+const minShare = 1000
921929
922930 // ResizeRow changes the focused window's height share by pct percent; the
923931 // other rows of its column give or take it in proportion to their shares.
924-// Every row keeps at least minShare percent.
932+// Every row keeps at least minShare basis points.
925933 func (w *Workspace) ResizeRow(pct int) {
926934 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
927935 return
928936 }
929937 c := &w.Columns[w.Focus]
930938 n := len(c.Windows)
931- if n < 2 || n*minShare > 100 {
939+ if n < 2 || n*minShare > 10000 {
932940 return
933941 }
934942 shares := c.Shares
935943 if !validShares(*c) {
936944 shares = make([]int, n)
937945 for i := range shares {
938- shares[i] = 100 / n
946+ shares[i] = 10000 / n
939947 }
940- shares[n-1] += 100 % n
948+ shares[n-1] += 10000 % n
941949 } else {
942950 shares = slices.Clone(shares)
943951 }
944952 f := c.Focus
945- target := min(max(shares[f]+pct, minShare), 100-minShare*(n-1))
953+ target := min(max(shares[f]+pct*100, minShare), 10000-minShare*(n-1))
946954 delta := target - shares[f]
947955 if delta == 0 {
948956 return
@@ -1033,6 +1041,8 @@ func (w *Workspace) insertColumn(at int, col Column) {
10331041 // insertAt inserts col at index at; in a cascade it joins the band of the
10341042 // column it goes before.
10351043 func (w *Workspace) insertAt(at int, col Column) {
1044+ // Stamp identities belong to the issuing workspace and complete lane.
1045+ col.Span, col.spanStamp = 0, 0
10361046 col.BandStart = false
10371047 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
10381048 col.BandStart, w.Columns[at].BandStart = true, false
@@ -1051,6 +1061,7 @@ func (w *Workspace) GrowColumn(cycle bool) {
10511061 return
10521062 }
10531063 defer w.scroll()
1064+ w.clearSpans(w.Focus)
10541065 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
10551066 w.unmaximizeBand()
10561067 }
@@ -1120,6 +1131,7 @@ func (w *Workspace) ShrinkColumn() {
11201131 return
11211132 }
11221133 defer w.scroll()
1134+ w.clearSpans(w.Focus)
11231135 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
11241136 w.unmaximizeBand()
11251137 }
@@ -1667,6 +1679,9 @@ func (w *Workspace) scrollWidth(i int) int {
16671679 // Fixed overflow past MaxColumns: the spiral's slots.
16681680 return w.equalShare(k)
16691681 }
1682+ if w.validSpans(0, len(w.Columns)-1) {
1683+ return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
1684+ }
16701685 c := w.Columns[i].Cells
16711686 if c <= 0 {
16721687 return w.equalShare(min(len(w.Columns), k))
@@ -1682,6 +1697,7 @@ func (w *Workspace) columnX(i int) int {
16821697 return x
16831698 }
16841699 func (w *Workspace) scroll() {
1700+ w.dropStaleSpans()
16851701 before := w.View
16861702 defer func() { w.retarget(before) }()
16871703 if w.policy().wraps {
@@ -1810,7 +1826,7 @@ func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
18101826 avail := max(r.H-(n-1)*gap, 0)
18111827 yy, bottom, used := r.Y, r.Y+r.H, 0
18121828 for i, share := range c.Shares {
1813- hh := avail * share / 100
1829+ hh := avail * share / 10000
18141830 if i == n-1 {
18151831 hh = avail - used
18161832 }
@@ -1824,7 +1840,7 @@ func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
18241840 }
18251841
18261842 // validShares reports whether c.Shares has one share per window, each at
1827-// least 1, summing to 100.
1843+// least 1, summing to 10000.
18281844 func validShares(c Column) bool {
18291845 if len(c.Shares) != len(c.Windows) || len(c.Shares) < 2 {
18301846 return false
@@ -1836,7 +1852,7 @@ func validShares(c Column) bool {
18361852 }
18371853 sum += v
18381854 }
1839- return sum == 100
1855+ return sum == 10000
18401856 }
18411857
18421858 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
@@ -19,6 +19,9 @@ const maxCells = 16
19 // max-columns-th of the usable width. Zero (auto) shares the width equally19 // max-columns-th of the usable width. Zero (auto) shares the width equally
20 // with the other auto columns, up to MaxColumns visible at once.20 // with the other auto columns, up to MaxColumns visible at once.
21 type Column struct {21 type Column struct {
22+ // Span is a manually sized width in basis points; zero uses Cells.
23+ Span int
24+ spanStamp uint64
22 Windows []WindowID25 Windows []WindowID
23 Cells int26 Cells int
24 FullWidth bool27 FullWidth bool
@@ -26,9 +29,9 @@ type Column struct {
26 // Slot is the declared column number (workspace.<name>.column.N) of a29 // Slot is the declared column number (workspace.<name>.column.N) of a
27 // slot window; 0 for normal columns.30 // slot window; 0 for normal columns.
28 Slot int31 Slot int
29- // Shares are the row heights set by set-window-height, in percent, in32+ // Shares are the row heights set by set-window-height, in basis points, in
30 // Windows order. They apply only while there is one per window and33 // Windows order. They apply only while there is one per window and
31- // they sum to 100; otherwise the rows share the height equally.34+ // they sum to 10000; otherwise the rows share the height equally.
32 Shares []int35 Shares []int
33 // BandStart marks the first column of a cascade band. It belongs to the36 // BandStart marks the first column of a cascade band. It belongs to the
34 // position, not the column: swaps leave it in place. See cascade.go.37 // position, not the column: swaps leave it in place. See cascade.go.
@@ -105,6 +108,8 @@ type Workspace struct {
105 overviewAfter *Workspace108 overviewAfter *Workspace
106 Overflow Overflow109 Overflow Overflow
107 Columns []Column110 Columns []Column
111+ // spanSeq issues workspace-local identities for complete lane stamps.
112+ spanSeq uint64
108 // maximized remembers recently maximized columns by window ID, newest113 // maximized remembers recently maximized columns by window ID, newest
109 // first; stack() filters removed windows at read time.114 // first; stack() filters removed windows at read time.
110 maximized []WindowID115 maximized []WindowID
@@ -534,6 +539,7 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
534 at = min(w.Focus+1, len(w.Columns))539 at = min(w.Focus+1, len(w.Columns))
535 }540 }
536 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})541 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
542+ w.dropStaleSpans()
537 return543 return
538 }544 }
539 w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})545 w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
@@ -542,6 +548,8 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
542 // addColumn places a new column like a new window and focuses it.548 // addColumn places a new column like a new window and focuses it.
543 func (w *Workspace) addColumn(col Column) {549 func (w *Workspace) addColumn(col Column) {
544 at := 0550 at := 0
551+ // Stamp identities belong to the issuing workspace and complete lane.
552+ col.Span, col.spanStamp = 0, 0
545 col.BandStart = false553 col.BandStart = false
546 if w.policy().wraps {554 if w.policy().wraps {
547 // After the focused band: it joins it when it has room, else it555 // After the focused band: it joins it when it has room, else it
@@ -916,33 +924,33 @@ func (w *Workspace) MoveWindow(dir int) {
916 w.scroll()924 w.scroll()
917 }925 }
918 926
919-// minShare is the smallest row height set-window-height leaves, percent.927+// minShare is the smallest row height set-window-height leaves, in basis points.
920-const minShare = 10928+const minShare = 1000
921 929
922 // ResizeRow changes the focused window's height share by pct percent; the930 // ResizeRow changes the focused window's height share by pct percent; the
923 // other rows of its column give or take it in proportion to their shares.931 // other rows of its column give or take it in proportion to their shares.
924-// Every row keeps at least minShare percent.932+// Every row keeps at least minShare basis points.
925 func (w *Workspace) ResizeRow(pct int) {933 func (w *Workspace) ResizeRow(pct int) {
926 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {934 if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
927 return935 return
928 }936 }
929 c := &w.Columns[w.Focus]937 c := &w.Columns[w.Focus]
930 n := len(c.Windows)938 n := len(c.Windows)
931- if n < 2 || n*minShare > 100 {939+ if n < 2 || n*minShare > 10000 {
932 return940 return
933 }941 }
934 shares := c.Shares942 shares := c.Shares
935 if !validShares(*c) {943 if !validShares(*c) {
936 shares = make([]int, n)944 shares = make([]int, n)
937 for i := range shares {945 for i := range shares {
938- shares[i] = 100 / n946+ shares[i] = 10000 / n
939 }947 }
940- shares[n-1] += 100 % n948+ shares[n-1] += 10000 % n
941 } else {949 } else {
942 shares = slices.Clone(shares)950 shares = slices.Clone(shares)
943 }951 }
944 f := c.Focus952 f := c.Focus
945- target := min(max(shares[f]+pct, minShare), 100-minShare*(n-1))953+ target := min(max(shares[f]+pct*100, minShare), 10000-minShare*(n-1))
946 delta := target - shares[f]954 delta := target - shares[f]
947 if delta == 0 {955 if delta == 0 {
948 return956 return
@@ -1033,6 +1041,8 @@ func (w *Workspace) insertColumn(at int, col Column) {
1033 // insertAt inserts col at index at; in a cascade it joins the band of the1041 // insertAt inserts col at index at; in a cascade it joins the band of the
1034 // column it goes before.1042 // column it goes before.
1035 func (w *Workspace) insertAt(at int, col Column) {1043 func (w *Workspace) insertAt(at int, col Column) {
1044+ // Stamp identities belong to the issuing workspace and complete lane.
1045+ col.Span, col.spanStamp = 0, 0
1036 col.BandStart = false1046 col.BandStart = false
1037 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {1047 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
1038 col.BandStart, w.Columns[at].BandStart = true, false1048 col.BandStart, w.Columns[at].BandStart = true, false
@@ -1051,6 +1061,7 @@ func (w *Workspace) GrowColumn(cycle bool) {
1051 return1061 return
1052 }1062 }
1053 defer w.scroll()1063 defer w.scroll()
1064+ w.clearSpans(w.Focus)
1054 if w.policy().wraps && w.Columns[w.Focus].FullWidth {1065 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1055 w.unmaximizeBand()1066 w.unmaximizeBand()
1056 }1067 }
@@ -1120,6 +1131,7 @@ func (w *Workspace) ShrinkColumn() {
1120 return1131 return
1121 }1132 }
1122 defer w.scroll()1133 defer w.scroll()
1134+ w.clearSpans(w.Focus)
1123 if w.policy().wraps && w.Columns[w.Focus].FullWidth {1135 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1124 w.unmaximizeBand()1136 w.unmaximizeBand()
1125 }1137 }
@@ -1667,6 +1679,9 @@ func (w *Workspace) scrollWidth(i int) int {
1667 // Fixed overflow past MaxColumns: the spiral's slots.1679 // Fixed overflow past MaxColumns: the spiral's slots.
1668 return w.equalShare(k)1680 return w.equalShare(k)
1669 }1681 }
1682+ if w.validSpans(0, len(w.Columns)-1) {
1683+ return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
1684+ }
1670 c := w.Columns[i].Cells1685 c := w.Columns[i].Cells
1671 if c <= 0 {1686 if c <= 0 {
1672 return w.equalShare(min(len(w.Columns), k))1687 return w.equalShare(min(len(w.Columns), k))
@@ -1682,6 +1697,7 @@ func (w *Workspace) columnX(i int) int {
1682 return x1697 return x
1683 }1698 }
1684 func (w *Workspace) scroll() {1699 func (w *Workspace) scroll() {
1700+ w.dropStaleSpans()
1685 before := w.View1701 before := w.View
1686 defer func() { w.retarget(before) }()1702 defer func() { w.retarget(before) }()
1687 if w.policy().wraps {1703 if w.policy().wraps {
@@ -1810,7 +1826,7 @@ func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
1810 avail := max(r.H-(n-1)*gap, 0)1826 avail := max(r.H-(n-1)*gap, 0)
1811 yy, bottom, used := r.Y, r.Y+r.H, 01827 yy, bottom, used := r.Y, r.Y+r.H, 0
1812 for i, share := range c.Shares {1828 for i, share := range c.Shares {
1813- hh := avail * share / 1001829+ hh := avail * share / 10000
1814 if i == n-1 {1830 if i == n-1 {
1815 hh = avail - used1831 hh = avail - used
1816 }1832 }
@@ -1824,7 +1840,7 @@ func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
1824 }1840 }
1825 1841
1826 // validShares reports whether c.Shares has one share per window, each at1842 // validShares reports whether c.Shares has one share per window, each at
1827-// least 1, summing to 100.1843+// least 1, summing to 10000.
1828 func validShares(c Column) bool {1844 func validShares(c Column) bool {
1829 if len(c.Shares) != len(c.Windows) || len(c.Shares) < 2 {1845 if len(c.Shares) != len(c.Windows) || len(c.Shares) < 2 {
1830 return false1846 return false
@@ -1836,7 +1852,7 @@ func validShares(c Column) bool {
1836 }1852 }
1837 sum += v1853 sum += v
1838 }1854 }
1839- return sum == 1001855+ return sum == 10000
1840 }1856 }
1841 1857
1842 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.1858 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
M internal/core/layout_buffers_test.go +2 -2
@@ -39,7 +39,7 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
3939 m = base(OverflowScroll, 4)
4040 w := m.Current()
4141 w.Columns[0].Windows = []WindowID{1, 5, 6}
42- w.Columns[0].Shares = []int{50, 30, 20}
42+ w.Columns[0].Shares = []int{5000, 3000, 2000}
4343 m.AddWindow(5)
4444 v["shares"] = m
4545
@@ -204,7 +204,7 @@ func TestRectHelpersKeepAppendSemantics(t *testing.T) {
204204 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {
205205 t.Fatalf("stackRectsInto: %+v", got)
206206 }
207- c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{50, 30, 20}}
207+ c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{5000, 3000, 2000}}
208208 got = rowRectsInto(slices.Clone(pre), r, c, 5)
209209 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {
210210 t.Fatalf("rowRectsInto: %+v", got)
@@ -39,7 +39,7 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
39 m = base(OverflowScroll, 4)39 m = base(OverflowScroll, 4)
40 w := m.Current()40 w := m.Current()
41 w.Columns[0].Windows = []WindowID{1, 5, 6}41 w.Columns[0].Windows = []WindowID{1, 5, 6}
42- w.Columns[0].Shares = []int{50, 30, 20}42+ w.Columns[0].Shares = []int{5000, 3000, 2000}
43 m.AddWindow(5)43 m.AddWindow(5)
44 v["shares"] = m44 v["shares"] = m
45 45
@@ -204,7 +204,7 @@ func TestRectHelpersKeepAppendSemantics(t *testing.T) {
204 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {204 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {
205 t.Fatalf("stackRectsInto: %+v", got)205 t.Fatalf("stackRectsInto: %+v", got)
206 }206 }
207- c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{50, 30, 20}}207+ c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{5000, 3000, 2000}}
208 got = rowRectsInto(slices.Clone(pre), r, c, 5)208 got = rowRectsInto(slices.Clone(pre), r, c, 5)
209 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {209 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {
210 t.Fatalf("rowRectsInto: %+v", got)210 t.Fatalf("rowRectsInto: %+v", got)
M internal/core/layout_policy.go +58 -0
@@ -53,3 +53,61 @@ func (w *Workspace) policy() layoutPolicy {
5353 }
5454 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
5555 }
56+
57+// validSpans requires a complete, exclusive lane stamp: every column has
58+// a positive span and the same nonzero identity, with no members elsewhere
59+// in the workspace. Equal-cell lanes must also sum to 10000 basis points.
60+func (w *Workspace) validSpans(first, last int) bool {
61+ if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
62+ return false
63+ }
64+ stamp := w.Columns[first].spanStamp
65+ if stamp == 0 {
66+ return false
67+ }
68+ total := 0
69+ for i := first; i <= last; i++ {
70+ c := w.Columns[i]
71+ if c.Span <= 0 || c.spanStamp != stamp {
72+ return false
73+ }
74+ total += c.Span
75+ }
76+ count := 0
77+ for _, c := range w.Columns {
78+ if c.spanStamp == stamp {
79+ count++
80+ }
81+ }
82+ return count == last-first+1 && (!w.policy().equalCells || total == 10000)
83+}
84+
85+// dropStaleSpans runs before scroll measures mutated layout geometry. Decide
86+// validity before clearing any stamps, so shared identities cannot become
87+// valid merely because an earlier invalid lane was cleared.
88+func (w *Workspace) dropStaleSpans() {
89+ var stale []int
90+ for first := 0; first < len(w.Columns); {
91+ _, last := w.laneBounds(w.laneOf(first))
92+ if !w.validSpans(first, last) {
93+ for i := first; i <= last; i++ {
94+ stale = append(stale, i)
95+ }
96+ }
97+ first = last + 1
98+ }
99+ for _, i := range stale {
100+ w.Columns[i].Span, w.Columns[i].spanStamp = 0, 0
101+ }
102+}
103+
104+func (w *Workspace) clearSpans(col int) {
105+ if col < 0 || col >= len(w.Columns) {
106+ return
107+ }
108+ first, last := w.laneBounds(w.laneOf(col))
109+ for i := first; i <= last; i++ {
110+ w.Columns[i].Span = 0
111+ w.Columns[i].spanStamp = 0
112+ }
113+}
@@ -53,3 +53,61 @@ func (w *Workspace) policy() layoutPolicy {
53 }53 }
54 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}54 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
55 }55 }
56+
57+// validSpans requires a complete, exclusive lane stamp: every column has
58+// a positive span and the same nonzero identity, with no members elsewhere
59+// in the workspace. Equal-cell lanes must also sum to 10000 basis points.
60+func (w *Workspace) validSpans(first, last int) bool {
61+ if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
62+ return false
63+ }
64+ stamp := w.Columns[first].spanStamp
65+ if stamp == 0 {
66+ return false
67+ }
68+ total := 0
69+ for i := first; i <= last; i++ {
70+ c := w.Columns[i]
71+ if c.Span <= 0 || c.spanStamp != stamp {
72+ return false
73+ }
74+ total += c.Span
75+ }
76+ count := 0
77+ for _, c := range w.Columns {
78+ if c.spanStamp == stamp {
79+ count++
80+ }
81+ }
82+ return count == last-first+1 && (!w.policy().equalCells || total == 10000)
83+}
84+
85+// dropStaleSpans runs before scroll measures mutated layout geometry. Decide
86+// validity before clearing any stamps, so shared identities cannot become
87+// valid merely because an earlier invalid lane was cleared.
88+func (w *Workspace) dropStaleSpans() {
89+ var stale []int
90+ for first := 0; first < len(w.Columns); {
91+ _, last := w.laneBounds(w.laneOf(first))
92+ if !w.validSpans(first, last) {
93+ for i := first; i <= last; i++ {
94+ stale = append(stale, i)
95+ }
96+ }
97+ first = last + 1
98+ }
99+ for _, i := range stale {
100+ w.Columns[i].Span, w.Columns[i].spanStamp = 0, 0
101+ }
102+}
103+
104+func (w *Workspace) clearSpans(col int) {
105+ if col < 0 || col >= len(w.Columns) {
106+ return
107+ }
108+ first, last := w.laneBounds(w.laneOf(col))
109+ for i := first; i <= last; i++ {
110+ w.Columns[i].Span = 0
111+ w.Columns[i].spanStamp = 0
112+ }
113+}
M internal/core/layout_test.go +9 -5
@@ -50,15 +50,19 @@ func TestLaneWidths(t *testing.T) {
5050 t.Errorf("2,auto,auto: got %v want %v", got, want)
5151 }
5252 // No room for them: an auto column weighs one cell.
53- if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) {
53+ if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail - avail*3/5 - avail/5}; !reflect.DeepEqual(got, want) {
5454 t.Errorf("3,auto,auto: got %v want %v", got, want)
5555 }
56- // All auto: the equal share of n columns, pixel for pixel.
56+ // All auto: equal shares, with remaining pixels in the last column.
5757 for n := 1; n <= 4; n++ {
5858 w := mk(n)
5959 for i := range w.Columns {
60- if got := w.cellWidth(i); got != w.equalShare(n) {
61- t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n))
60+ want := w.equalShare(n)
61+ if i == n-1 {
62+ want = 1000 - 8*(n+1) - (n-1)*want
63+ }
64+ if got := w.cellWidth(i); got != want {
65+ t.Errorf("%d auto columns: width %d want %d", n, got, want)
6266 }
6367 }
6468 }
@@ -510,7 +514,7 @@ func TestFixedGrowColumn(t *testing.T) {
510514 check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})
511515 // No donor is left: back to equal shares.
512516 w.GrowColumn(true)
513- check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}})
517+ check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 34, H: 80}})
514518 })
515519 t.Run("with gaps", func(t *testing.T) {
516520 w := fixedWorkspace(4, 4, 2)
@@ -50,15 +50,19 @@ func TestLaneWidths(t *testing.T) {
50 t.Errorf("2,auto,auto: got %v want %v", got, want)50 t.Errorf("2,auto,auto: got %v want %v", got, want)
51 }51 }
52 // No room for them: an auto column weighs one cell.52 // No room for them: an auto column weighs one cell.
53- if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) {53+ if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail - avail*3/5 - avail/5}; !reflect.DeepEqual(got, want) {
54 t.Errorf("3,auto,auto: got %v want %v", got, want)54 t.Errorf("3,auto,auto: got %v want %v", got, want)
55 }55 }
56- // All auto: the equal share of n columns, pixel for pixel.56+ // All auto: equal shares, with remaining pixels in the last column.
57 for n := 1; n <= 4; n++ {57 for n := 1; n <= 4; n++ {
58 w := mk(n)58 w := mk(n)
59 for i := range w.Columns {59 for i := range w.Columns {
60- if got := w.cellWidth(i); got != w.equalShare(n) {60+ want := w.equalShare(n)
61- t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n))61+ if i == n-1 {
62+ want = 1000 - 8*(n+1) - (n-1)*want
63+ }
64+ if got := w.cellWidth(i); got != want {
65+ t.Errorf("%d auto columns: width %d want %d", n, got, want)
62 }66 }
63 }67 }
64 }68 }
@@ -510,7 +514,7 @@ func TestFixedGrowColumn(t *testing.T) {
510 check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})514 check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})
511 // No donor is left: back to equal shares.515 // No donor is left: back to equal shares.
512 w.GrowColumn(true)516 w.GrowColumn(true)
513- check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}})517+ check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 34, H: 80}})
514 })518 })
515 t.Run("with gaps", func(t *testing.T) {519 t.Run("with gaps", func(t *testing.T) {
516 w := fixedWorkspace(4, 4, 2)520 w := fixedWorkspace(4, 4, 2)
A internal/core/pointergrab.go +95 -0
@@ -0,0 +1,95 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// pointerGrab is a compositor-owned pointer interaction started by cmd+button.
11+// Clients get no pointer events while it runs. Implementations: borderResize
12+// (border_resize.go); planned: column move within a workspace, and edge-hold
13+// transfer of a dragged column to the next workspace or the adjacent monitor.
14+type pointerGrab interface {
15+ // valid reports whether the grab still applies to the current layout; the core aborts it otherwise.
16+ valid() bool
17+ button() uint32
18+ motion(ctx context.Context, at time.Duration) error
19+ release(ctx context.Context) error
20+ cancel() // Escape: restore the state from before the grab
21+ cursor() string // CSS cursor name; "" = the client's cursor
22+}
23+
24+func (c *Core) pointerBusy() bool { return c.drag != nil || c.pgrab != nil }
25+
26+func (c *Core) startPointerGrab(ctx context.Context, v ports.PointerButton) (bool, error) {
27+ if c.synthetic || !v.Pressed || c.pointerBusy() || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 || c.keyboard.inhibited(c.keyboardFocus()) {
28+ return false, nil
29+ }
30+ if v.Button == btnRight {
31+ if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 {
32+ s := c.screens[i]
33+ w := s.mon.Current()
34+ lx, ly := int(c.cursorX)-s.x, int(c.cursorY)-s.y
35+ if !s.mon.previewing() && !s.mon.ov.open && !w.pinned() && s.mon.frameHas(float64(lx), float64(ly)) {
36+ if b, ok := w.borderAt(lx, ly, max(w.gap()/2, gapPad)); ok {
37+ last := c.lastBorderClick
38+ if last.ws == w && last.b.kind == b.kind && last.b.col == b.col && last.b.row == b.row && v.Time >= last.at && v.Time-last.at <= 400*time.Millisecond {
39+ c.lastBorderClick.ws = nil
40+ now := c.now()
41+ shots := c.snapshot(now)
42+ w.resetBorder(b)
43+ c.transition(shots, now)
44+ c.buttons[v.Button], c.swallow[v.Button] = true, true
45+ return true, c.publish(ctx)
46+ }
47+ c.lastBorderClick.at, c.lastBorderClick.ws, c.lastBorderClick.b = v.Time, w, b
48+ g := newBorderResize(c, s, w, b, v.Button)
49+ return true, c.beginPointerGrab(ctx, g)
50+ }
51+ }
52+ }
53+ }
54+ c.lastBorderClick.ws = nil
55+ return c.startDrag(ctx, v)
56+}
57+
58+func (c *Core) beginPointerGrab(ctx context.Context, g pointerGrab) error {
59+ if err := c.dismissGrabs(ctx, 0); err != nil {
60+ return err
61+ }
62+ c.pgrab = g
63+ c.buttons[g.button()] = true
64+ c.grab = 0
65+ if c.pointer != 0 {
66+ c.pointer = 0
67+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
68+ return err
69+ }
70+ }
71+ if err := c.command(ctx, ports.CursorOverride{Shape: g.cursor()}); err != nil {
72+ return err
73+ }
74+ return c.publish(ctx)
75+}
76+
77+func (c *Core) endPointerGrab(ctx context.Context) error {
78+ g := c.pgrab
79+ c.pgrab = nil
80+ if err := c.command(ctx, ports.CursorOverride{}); err != nil {
81+ return err
82+ }
83+ return g.release(ctx)
84+}
85+
86+// abortPointerGrab keeps the current layout and swallows the held release.
87+// The caller restores state first when this is an Escape cancellation.
88+func (c *Core) abortPointerGrab(ctx context.Context) error {
89+ if c.pgrab == nil {
90+ return nil
91+ }
92+ c.swallow[c.pgrab.button()] = true
93+ c.pgrab = nil
94+ return c.command(ctx, ports.CursorOverride{})
95+}
@@ -0,0 +1,95 @@
1+package core
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// pointerGrab is a compositor-owned pointer interaction started by cmd+button.
11+// Clients get no pointer events while it runs. Implementations: borderResize
12+// (border_resize.go); planned: column move within a workspace, and edge-hold
13+// transfer of a dragged column to the next workspace or the adjacent monitor.
14+type pointerGrab interface {
15+ // valid reports whether the grab still applies to the current layout; the core aborts it otherwise.
16+ valid() bool
17+ button() uint32
18+ motion(ctx context.Context, at time.Duration) error
19+ release(ctx context.Context) error
20+ cancel() // Escape: restore the state from before the grab
21+ cursor() string // CSS cursor name; "" = the client's cursor
22+}
23+
24+func (c *Core) pointerBusy() bool { return c.drag != nil || c.pgrab != nil }
25+
26+func (c *Core) startPointerGrab(ctx context.Context, v ports.PointerButton) (bool, error) {
27+ if c.synthetic || !v.Pressed || c.pointerBusy() || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 || c.keyboard.inhibited(c.keyboardFocus()) {
28+ return false, nil
29+ }
30+ if v.Button == btnRight {
31+ if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 {
32+ s := c.screens[i]
33+ w := s.mon.Current()
34+ lx, ly := int(c.cursorX)-s.x, int(c.cursorY)-s.y
35+ if !s.mon.previewing() && !s.mon.ov.open && !w.pinned() && s.mon.frameHas(float64(lx), float64(ly)) {
36+ if b, ok := w.borderAt(lx, ly, max(w.gap()/2, gapPad)); ok {
37+ last := c.lastBorderClick
38+ if last.ws == w && last.b.kind == b.kind && last.b.col == b.col && last.b.row == b.row && v.Time >= last.at && v.Time-last.at <= 400*time.Millisecond {
39+ c.lastBorderClick.ws = nil
40+ now := c.now()
41+ shots := c.snapshot(now)
42+ w.resetBorder(b)
43+ c.transition(shots, now)
44+ c.buttons[v.Button], c.swallow[v.Button] = true, true
45+ return true, c.publish(ctx)
46+ }
47+ c.lastBorderClick.at, c.lastBorderClick.ws, c.lastBorderClick.b = v.Time, w, b
48+ g := newBorderResize(c, s, w, b, v.Button)
49+ return true, c.beginPointerGrab(ctx, g)
50+ }
51+ }
52+ }
53+ }
54+ c.lastBorderClick.ws = nil
55+ return c.startDrag(ctx, v)
56+}
57+
58+func (c *Core) beginPointerGrab(ctx context.Context, g pointerGrab) error {
59+ if err := c.dismissGrabs(ctx, 0); err != nil {
60+ return err
61+ }
62+ c.pgrab = g
63+ c.buttons[g.button()] = true
64+ c.grab = 0
65+ if c.pointer != 0 {
66+ c.pointer = 0
67+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
68+ return err
69+ }
70+ }
71+ if err := c.command(ctx, ports.CursorOverride{Shape: g.cursor()}); err != nil {
72+ return err
73+ }
74+ return c.publish(ctx)
75+}
76+
77+func (c *Core) endPointerGrab(ctx context.Context) error {
78+ g := c.pgrab
79+ c.pgrab = nil
80+ if err := c.command(ctx, ports.CursorOverride{}); err != nil {
81+ return err
82+ }
83+ return g.release(ctx)
84+}
85+
86+// abortPointerGrab keeps the current layout and swallows the held release.
87+// The caller restores state first when this is an Escape cancellation.
88+func (c *Core) abortPointerGrab(ctx context.Context) error {
89+ if c.pgrab == nil {
90+ return nil
91+ }
92+ c.swallow[c.pgrab.button()] = true
93+ c.pgrab = nil
94+ return c.command(ctx, ports.CursorOverride{})
95+}
A internal/core/pointergrab_test.go +291 -0
@@ -0,0 +1,291 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+type pointerRig struct {
14+ *Core
15+ commands chan ports.ClientCommand
16+}
17+
18+func borderCore(t *testing.T, row bool) (*pointerRig, *Workspace) {
19+ t.Helper()
20+ commands := make(chan ports.ClientCommand, 1024)
21+ c := &pointerRig{Core: dragCore(t), commands: commands}
22+ c.ch.Commands = commands
23+ w := c.cur().mon.Current()
24+ w.Overflow = OverflowFixed
25+ w.SetMaxColumns(2)
26+ w.SetGaps(10)
27+ w.AddWindow(1)
28+ w.AddWindow(2)
29+ if row {
30+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
31+ w.Focus = 0
32+ }
33+ rs := w.columnRects()
34+ c.cursorX, c.cursorY = float64(rs[0].X+rs[0].W+w.gap()/2), 100
35+ if row {
36+ rows := rowRects(rs[0], w.Columns[0], w.gap())
37+ c.cursorX, c.cursorY = 100, float64(rows[0].Y+rows[0].H+w.gap()/2)
38+ }
39+ c.mods = ports.ModSuper
40+ return c, w
41+}
42+
43+func grabButton(t *testing.T, c *pointerRig, b uint32, down bool, at time.Duration) {
44+ t.Helper()
45+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: b, Pressed: down, Time: at}); err != nil {
46+ t.Fatal(err)
47+ }
48+}
49+
50+func grabMove(t *testing.T, c *pointerRig, x, y float64) {
51+ t.Helper()
52+ if err := c.pointerMotion(context.Background(), ports.PointerMotion{X: x, Y: y, Time: time.Second}); err != nil {
53+ t.Fatal(err)
54+ }
55+}
56+
57+func grabCommands(c *pointerRig) []ports.ClientCommand {
58+ var out []ports.ClientCommand
59+ for len(c.commands) > 0 {
60+ out = append(out, <-c.commands)
61+ }
62+ return out
63+}
64+
65+func requireOverride(t *testing.T, cmds []ports.ClientCommand, shape string) {
66+ t.Helper()
67+ if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool { v, ok := cmd.(ports.CursorOverride); return ok && v.Shape == shape }) {
68+ t.Fatalf("missing cursor %q in %+v", shape, cmds)
69+ }
70+}
71+
72+func TestBorderPointerGrab(t *testing.T) {
73+ for _, row := range []bool{false, true} {
74+ t.Run(map[bool]string{false: "column", true: "row"}[row], func(t *testing.T) {
75+ c, w := borderCore(t, row)
76+ before := w.Layout()
77+ grabButton(t, c, btnRight, true, time.Second)
78+ if c.pgrab == nil || c.pointer != 0 || c.drag != nil {
79+ t.Fatal("no exclusive border grab")
80+ }
81+ shape := "ew-resize"
82+ if row {
83+ shape = "ns-resize"
84+ }
85+ requireOverride(t, grabCommands(c), shape)
86+ x, y := c.cursorX+150, c.cursorY
87+ if row {
88+ x, y = c.cursorX, c.cursorY+100
89+ }
90+ grabMove(t, c, x, y)
91+ after := w.Layout()
92+ if before[0].Rect == after[0].Rect || before[1].Rect == after[1].Rect {
93+ t.Fatal("tiles did not resize", before, after)
94+ }
95+ cmds := grabCommands(c)
96+ for _, id := range []WindowID{1, 2} {
97+ if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool {
98+ v, ok := cmd.(ports.ConfigureWindow)
99+ r := after[int(id)-1].Rect
100+ return ok && v.ID == id && v.Width == r.W-2*w.border && v.Height == r.H-2*w.border
101+ }) {
102+ t.Fatalf("no live configure for %d: %+v", id, cmds)
103+ }
104+ }
105+ grabButton(t, c, btnRight, false, 2*time.Second)
106+ if c.pgrab != nil || !slices.Equal(w.Layout(), after) {
107+ t.Fatal("release changed sizes")
108+ }
109+ requireOverride(t, grabCommands(c), "")
110+ })
111+ }
112+}
113+
114+func TestBorderGrabEscape(t *testing.T) {
115+ for _, row := range []bool{false, true} {
116+ c, w := borderCore(t, row)
117+ before := w.Layout()
118+ grabButton(t, c, btnRight, true, time.Second)
119+ grabMove(t, c, c.cursorX+100, c.cursorY+100)
120+ grabCommands(c)
121+ if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Escape", Pressed: true}); err != nil {
122+ t.Fatal(err)
123+ }
124+ if c.pgrab != nil || !slices.Equal(w.Layout(), before) {
125+ t.Fatal("cancel did not restore", w.Layout(), before)
126+ }
127+ requireOverride(t, grabCommands(c), "")
128+ if !c.swallow[btnRight] {
129+ t.Fatal("release not swallowed")
130+ }
131+ grabButton(t, c, btnRight, false, 2*time.Second)
132+ if c.buttons[btnRight] || c.swallow[btnRight] {
133+ t.Fatal("release still held")
134+ }
135+ }
136+}
137+
138+func TestBorderGrabOtherButtons(t *testing.T) {
139+ c, _ := borderCore(t, false)
140+ grabButton(t, c, btnRight, true, time.Second)
141+ grabCommands(c)
142+ grabButton(t, c, btnLeft, true, time.Second)
143+ grabButton(t, c, btnLeft, false, time.Second)
144+ if c.pgrab == nil || len(grabCommands(c)) != 0 || c.buttons[btnLeft] {
145+ t.Fatal("other button escaped grab")
146+ }
147+}
148+
149+func TestBorderGrabPreconditions(t *testing.T) {
150+ for _, kind := range []string{"no-cmd", "interior", "overview", "preview", "synthetic", "held", "inhibited"} {
151+ t.Run(kind, func(t *testing.T) {
152+ c, _ := borderCore(t, false)
153+ switch kind {
154+ case "no-cmd":
155+ c.mods = 0
156+ case "interior":
157+ c.cursorX = 100
158+ case "overview":
159+ c.cur().mon.ov.open = true
160+ case "preview":
161+ c.cur().mon.sw = switcherState{ws: c.cur().mon.Current(), shown: true}
162+ case "synthetic":
163+ c.synthetic = true
164+ case "held":
165+ c.buttons[btnLeft] = true
166+ case "inhibited":
167+ c.keyboard.inhibiting = 1
168+ c.cur().mon.Current().FocusID(1)
169+ }
170+ started, err := c.startPointerGrab(context.Background(), ports.PointerButton{Button: btnRight, Pressed: true})
171+ if err != nil || started || c.pgrab != nil {
172+ t.Fatal(started, err)
173+ }
174+ })
175+ }
176+}
177+
178+func TestBorderGrabFloatFallback(t *testing.T) {
179+ c, w := borderCore(t, false)
180+ w.AddFloating(3, 200, 200)
181+ r := w.floatRect(w.Floats[0])
182+ c.pointer = 3
183+ c.cursorX, c.cursorY = float64(r.X+r.W/2), float64(r.Y+r.H/2)
184+ grabButton(t, c, btnRight, true, time.Second)
185+ if c.pgrab != nil || c.drag == nil || !c.drag.resize || !c.drag.float {
186+ t.Fatal("float resize lost")
187+ }
188+}
189+
190+func TestBorderDoubleClick(t *testing.T) {
191+ for _, row := range []bool{false, true} {
192+ c, w := borderCore(t, row)
193+ grabButton(t, c, btnRight, true, time.Second)
194+ grabMove(t, c, c.cursorX+100, c.cursorY+100)
195+ grabButton(t, c, btnRight, false, 1100*time.Millisecond)
196+ grabButton(t, c, btnRight, true, 1200*time.Millisecond)
197+ if c.pgrab != nil || !c.swallow[btnRight] || c.lastBorderClick.ws != nil {
198+ t.Fatal("not reset")
199+ }
200+ for _, col := range w.Columns {
201+ if col.Span != 0 || col.Shares != nil {
202+ t.Fatal("manual size remains", col)
203+ }
204+ }
205+ grabButton(t, c, btnRight, false, 1300*time.Millisecond)
206+ }
207+}
208+
209+func TestBorderGrabInvalidation(t *testing.T) {
210+ for _, change := range []string{"window", "workspace", "output"} {
211+ t.Run(change, func(t *testing.T) {
212+ c, w := borderCore(t, false)
213+ grabButton(t, c, btnRight, true, time.Second)
214+ grabCommands(c)
215+ switch change {
216+ case "window":
217+ if err := c.unmapClient(context.Background(), ports.WindowUnmapped{ID: 2}); err != nil {
218+ t.Fatal(err)
219+ }
220+ case "workspace":
221+ c.cur().mon.Workspaces = append(c.cur().mon.Workspaces, workspace())
222+ c.cur().mon.Active = len(c.cur().mon.Workspaces) - 1
223+ case "output":
224+ c.screens = []*screen{dragCore(t).cur()}
225+ }
226+ if c.pgrab != nil {
227+ if err := c.pgrab.motion(context.Background(), time.Second); err != nil {
228+ t.Fatal(err)
229+ }
230+ }
231+ if c.pgrab != nil || !c.swallow[btnRight] {
232+ t.Fatal("stale grab survives", w.Columns)
233+ }
234+ requireOverride(t, grabCommands(c), "")
235+ })
236+ }
237+}
238+
239+func TestBorderGrabSecurityReset(t *testing.T) {
240+ c, _ := borderCore(t, false)
241+ grabButton(t, c, btnRight, true, time.Second)
242+ grabCommands(c)
243+ gate := portsmocks.NewMockSessionSecurity(t)
244+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
245+ c.opts.Security = gate
246+ if !c.syncSecurity() || c.pgrab != nil {
247+ t.Fatal("grab survived protection")
248+ }
249+ if len(grabCommands(c)) != 0 {
250+ t.Fatal("security reset sent commands")
251+ }
252+}
253+
254+func TestPointerGrabValidityContract(t *testing.T) {
255+ c, _ := borderCore(t, false)
256+ grabButton(t, c, btnRight, true, time.Second)
257+ if !c.pgrab.valid() {
258+ t.Fatal("new grab is invalid")
259+ }
260+ c.screens = nil
261+ if c.pgrab.valid() {
262+ t.Fatal("grab survived output removal")
263+ }
264+}
265+
266+func TestBorderGrabFullscreenPublish(t *testing.T) {
267+ c, w := borderCore(t, false)
268+ w.Overflow = OverflowScroll
269+ grabButton(t, c, btnRight, true, time.Second)
270+ if c.pgrab == nil {
271+ t.Fatal("grab not started")
272+ }
273+ grabCommands(c)
274+ w.fullscreen = 1
275+ if err := c.publish(context.Background()); err != nil {
276+ t.Fatal(err)
277+ }
278+ if c.pgrab != nil || !c.swallow[btnRight] {
279+ t.Fatal("fullscreen did not abort grab and swallow release")
280+ }
281+ requireOverride(t, grabCommands(c), "")
282+ grabButton(t, c, btnRight, false, 2*time.Second)
283+ for _, cmd := range grabCommands(c) {
284+ if _, ok := cmd.(ports.PointerButtonTo); ok {
285+ t.Fatal("release forwarded")
286+ }
287+ }
288+ if c.buttons[btnRight] || c.swallow[btnRight] {
289+ t.Fatal("release still held")
290+ }
291+}
@@ -0,0 +1,291 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+type pointerRig struct {
14+ *Core
15+ commands chan ports.ClientCommand
16+}
17+
18+func borderCore(t *testing.T, row bool) (*pointerRig, *Workspace) {
19+ t.Helper()
20+ commands := make(chan ports.ClientCommand, 1024)
21+ c := &pointerRig{Core: dragCore(t), commands: commands}
22+ c.ch.Commands = commands
23+ w := c.cur().mon.Current()
24+ w.Overflow = OverflowFixed
25+ w.SetMaxColumns(2)
26+ w.SetGaps(10)
27+ w.AddWindow(1)
28+ w.AddWindow(2)
29+ if row {
30+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
31+ w.Focus = 0
32+ }
33+ rs := w.columnRects()
34+ c.cursorX, c.cursorY = float64(rs[0].X+rs[0].W+w.gap()/2), 100
35+ if row {
36+ rows := rowRects(rs[0], w.Columns[0], w.gap())
37+ c.cursorX, c.cursorY = 100, float64(rows[0].Y+rows[0].H+w.gap()/2)
38+ }
39+ c.mods = ports.ModSuper
40+ return c, w
41+}
42+
43+func grabButton(t *testing.T, c *pointerRig, b uint32, down bool, at time.Duration) {
44+ t.Helper()
45+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: b, Pressed: down, Time: at}); err != nil {
46+ t.Fatal(err)
47+ }
48+}
49+
50+func grabMove(t *testing.T, c *pointerRig, x, y float64) {
51+ t.Helper()
52+ if err := c.pointerMotion(context.Background(), ports.PointerMotion{X: x, Y: y, Time: time.Second}); err != nil {
53+ t.Fatal(err)
54+ }
55+}
56+
57+func grabCommands(c *pointerRig) []ports.ClientCommand {
58+ var out []ports.ClientCommand
59+ for len(c.commands) > 0 {
60+ out = append(out, <-c.commands)
61+ }
62+ return out
63+}
64+
65+func requireOverride(t *testing.T, cmds []ports.ClientCommand, shape string) {
66+ t.Helper()
67+ if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool { v, ok := cmd.(ports.CursorOverride); return ok && v.Shape == shape }) {
68+ t.Fatalf("missing cursor %q in %+v", shape, cmds)
69+ }
70+}
71+
72+func TestBorderPointerGrab(t *testing.T) {
73+ for _, row := range []bool{false, true} {
74+ t.Run(map[bool]string{false: "column", true: "row"}[row], func(t *testing.T) {
75+ c, w := borderCore(t, row)
76+ before := w.Layout()
77+ grabButton(t, c, btnRight, true, time.Second)
78+ if c.pgrab == nil || c.pointer != 0 || c.drag != nil {
79+ t.Fatal("no exclusive border grab")
80+ }
81+ shape := "ew-resize"
82+ if row {
83+ shape = "ns-resize"
84+ }
85+ requireOverride(t, grabCommands(c), shape)
86+ x, y := c.cursorX+150, c.cursorY
87+ if row {
88+ x, y = c.cursorX, c.cursorY+100
89+ }
90+ grabMove(t, c, x, y)
91+ after := w.Layout()
92+ if before[0].Rect == after[0].Rect || before[1].Rect == after[1].Rect {
93+ t.Fatal("tiles did not resize", before, after)
94+ }
95+ cmds := grabCommands(c)
96+ for _, id := range []WindowID{1, 2} {
97+ if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool {
98+ v, ok := cmd.(ports.ConfigureWindow)
99+ r := after[int(id)-1].Rect
100+ return ok && v.ID == id && v.Width == r.W-2*w.border && v.Height == r.H-2*w.border
101+ }) {
102+ t.Fatalf("no live configure for %d: %+v", id, cmds)
103+ }
104+ }
105+ grabButton(t, c, btnRight, false, 2*time.Second)
106+ if c.pgrab != nil || !slices.Equal(w.Layout(), after) {
107+ t.Fatal("release changed sizes")
108+ }
109+ requireOverride(t, grabCommands(c), "")
110+ })
111+ }
112+}
113+
114+func TestBorderGrabEscape(t *testing.T) {
115+ for _, row := range []bool{false, true} {
116+ c, w := borderCore(t, row)
117+ before := w.Layout()
118+ grabButton(t, c, btnRight, true, time.Second)
119+ grabMove(t, c, c.cursorX+100, c.cursorY+100)
120+ grabCommands(c)
121+ if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Escape", Pressed: true}); err != nil {
122+ t.Fatal(err)
123+ }
124+ if c.pgrab != nil || !slices.Equal(w.Layout(), before) {
125+ t.Fatal("cancel did not restore", w.Layout(), before)
126+ }
127+ requireOverride(t, grabCommands(c), "")
128+ if !c.swallow[btnRight] {
129+ t.Fatal("release not swallowed")
130+ }
131+ grabButton(t, c, btnRight, false, 2*time.Second)
132+ if c.buttons[btnRight] || c.swallow[btnRight] {
133+ t.Fatal("release still held")
134+ }
135+ }
136+}
137+
138+func TestBorderGrabOtherButtons(t *testing.T) {
139+ c, _ := borderCore(t, false)
140+ grabButton(t, c, btnRight, true, time.Second)
141+ grabCommands(c)
142+ grabButton(t, c, btnLeft, true, time.Second)
143+ grabButton(t, c, btnLeft, false, time.Second)
144+ if c.pgrab == nil || len(grabCommands(c)) != 0 || c.buttons[btnLeft] {
145+ t.Fatal("other button escaped grab")
146+ }
147+}
148+
149+func TestBorderGrabPreconditions(t *testing.T) {
150+ for _, kind := range []string{"no-cmd", "interior", "overview", "preview", "synthetic", "held", "inhibited"} {
151+ t.Run(kind, func(t *testing.T) {
152+ c, _ := borderCore(t, false)
153+ switch kind {
154+ case "no-cmd":
155+ c.mods = 0
156+ case "interior":
157+ c.cursorX = 100
158+ case "overview":
159+ c.cur().mon.ov.open = true
160+ case "preview":
161+ c.cur().mon.sw = switcherState{ws: c.cur().mon.Current(), shown: true}
162+ case "synthetic":
163+ c.synthetic = true
164+ case "held":
165+ c.buttons[btnLeft] = true
166+ case "inhibited":
167+ c.keyboard.inhibiting = 1
168+ c.cur().mon.Current().FocusID(1)
169+ }
170+ started, err := c.startPointerGrab(context.Background(), ports.PointerButton{Button: btnRight, Pressed: true})
171+ if err != nil || started || c.pgrab != nil {
172+ t.Fatal(started, err)
173+ }
174+ })
175+ }
176+}
177+
178+func TestBorderGrabFloatFallback(t *testing.T) {
179+ c, w := borderCore(t, false)
180+ w.AddFloating(3, 200, 200)
181+ r := w.floatRect(w.Floats[0])
182+ c.pointer = 3
183+ c.cursorX, c.cursorY = float64(r.X+r.W/2), float64(r.Y+r.H/2)
184+ grabButton(t, c, btnRight, true, time.Second)
185+ if c.pgrab != nil || c.drag == nil || !c.drag.resize || !c.drag.float {
186+ t.Fatal("float resize lost")
187+ }
188+}
189+
190+func TestBorderDoubleClick(t *testing.T) {
191+ for _, row := range []bool{false, true} {
192+ c, w := borderCore(t, row)
193+ grabButton(t, c, btnRight, true, time.Second)
194+ grabMove(t, c, c.cursorX+100, c.cursorY+100)
195+ grabButton(t, c, btnRight, false, 1100*time.Millisecond)
196+ grabButton(t, c, btnRight, true, 1200*time.Millisecond)
197+ if c.pgrab != nil || !c.swallow[btnRight] || c.lastBorderClick.ws != nil {
198+ t.Fatal("not reset")
199+ }
200+ for _, col := range w.Columns {
201+ if col.Span != 0 || col.Shares != nil {
202+ t.Fatal("manual size remains", col)
203+ }
204+ }
205+ grabButton(t, c, btnRight, false, 1300*time.Millisecond)
206+ }
207+}
208+
209+func TestBorderGrabInvalidation(t *testing.T) {
210+ for _, change := range []string{"window", "workspace", "output"} {
211+ t.Run(change, func(t *testing.T) {
212+ c, w := borderCore(t, false)
213+ grabButton(t, c, btnRight, true, time.Second)
214+ grabCommands(c)
215+ switch change {
216+ case "window":
217+ if err := c.unmapClient(context.Background(), ports.WindowUnmapped{ID: 2}); err != nil {
218+ t.Fatal(err)
219+ }
220+ case "workspace":
221+ c.cur().mon.Workspaces = append(c.cur().mon.Workspaces, workspace())
222+ c.cur().mon.Active = len(c.cur().mon.Workspaces) - 1
223+ case "output":
224+ c.screens = []*screen{dragCore(t).cur()}
225+ }
226+ if c.pgrab != nil {
227+ if err := c.pgrab.motion(context.Background(), time.Second); err != nil {
228+ t.Fatal(err)
229+ }
230+ }
231+ if c.pgrab != nil || !c.swallow[btnRight] {
232+ t.Fatal("stale grab survives", w.Columns)
233+ }
234+ requireOverride(t, grabCommands(c), "")
235+ })
236+ }
237+}
238+
239+func TestBorderGrabSecurityReset(t *testing.T) {
240+ c, _ := borderCore(t, false)
241+ grabButton(t, c, btnRight, true, time.Second)
242+ grabCommands(c)
243+ gate := portsmocks.NewMockSessionSecurity(t)
244+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
245+ c.opts.Security = gate
246+ if !c.syncSecurity() || c.pgrab != nil {
247+ t.Fatal("grab survived protection")
248+ }
249+ if len(grabCommands(c)) != 0 {
250+ t.Fatal("security reset sent commands")
251+ }
252+}
253+
254+func TestPointerGrabValidityContract(t *testing.T) {
255+ c, _ := borderCore(t, false)
256+ grabButton(t, c, btnRight, true, time.Second)
257+ if !c.pgrab.valid() {
258+ t.Fatal("new grab is invalid")
259+ }
260+ c.screens = nil
261+ if c.pgrab.valid() {
262+ t.Fatal("grab survived output removal")
263+ }
264+}
265+
266+func TestBorderGrabFullscreenPublish(t *testing.T) {
267+ c, w := borderCore(t, false)
268+ w.Overflow = OverflowScroll
269+ grabButton(t, c, btnRight, true, time.Second)
270+ if c.pgrab == nil {
271+ t.Fatal("grab not started")
272+ }
273+ grabCommands(c)
274+ w.fullscreen = 1
275+ if err := c.publish(context.Background()); err != nil {
276+ t.Fatal(err)
277+ }
278+ if c.pgrab != nil || !c.swallow[btnRight] {
279+ t.Fatal("fullscreen did not abort grab and swallow release")
280+ }
281+ requireOverride(t, grabCommands(c), "")
282+ grabButton(t, c, btnRight, false, 2*time.Second)
283+ for _, cmd := range grabCommands(c) {
284+ if _, ok := cmd.(ports.PointerButtonTo); ok {
285+ t.Fatal("release forwarded")
286+ }
287+ }
288+ if c.buttons[btnRight] || c.swallow[btnRight] {
289+ t.Fatal("release still held")
290+ }
291+}
M internal/core/row_shares_test.go +17 -17
@@ -29,15 +29,15 @@ func TestRowRectsEqualWithoutShares(t *testing.T) {
2929 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
3030 t.Fatal(rowRects(r, c, 5))
3131 }
32- c.Shares = []int{50, 50} // wrong length: ignored
32+ c.Shares = []int{5000, 5000} // wrong length: ignored
3333 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
3434 t.Fatal(rowRects(r, c, 5))
3535 }
36- c.Shares = []int{50, 30, 30} // wrong sum: ignored
36+ c.Shares = []int{5000, 3000, 3000} // wrong sum: ignored
3737 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
3838 t.Fatal(rowRects(r, c, 5))
3939 }
40- c.Shares = []int{50, 30, 20}
40+ c.Shares = []int{5000, 3000, 2000}
4141 rows := rowRects(r, c, 5)
4242 if rows[0].H != 46 || rows[1].H != 27 || rows[2].H != 93-46-27 || rows[2].Y+rows[2].H != 103 {
4343 t.Fatal(rows)
@@ -51,7 +51,7 @@ func TestResizeRow(t *testing.T) {
5151 w.SetOutput(1000, 800)
5252 w.Columns = []Column{{Windows: []WindowID{1, 2}}}
5353 w.Apply("set-window-height +10%")
54- if !slices.Equal(w.Columns[0].Shares, []int{60, 40}) {
54+ if !slices.Equal(w.Columns[0].Shares, []int{6000, 4000}) {
5555 t.Fatal(o, w.Columns[0].Shares)
5656 }
5757 h := heights(w)
@@ -61,13 +61,13 @@ func TestResizeRow(t *testing.T) {
6161 for range 10 {
6262 w.Apply("set-window-height +10%")
6363 }
64- if !slices.Equal(w.Columns[0].Shares, []int{90, 10}) {
64+ if !slices.Equal(w.Columns[0].Shares, []int{9000, 1000}) {
6565 t.Fatal(o, w.Columns[0].Shares)
6666 }
6767 for range 10 {
6868 w.Apply("set-window-height -10%")
6969 }
70- if !slices.Equal(w.Columns[0].Shares, []int{10, 90}) {
70+ if !slices.Equal(w.Columns[0].Shares, []int{1000, 9000}) {
7171 t.Fatal(o, w.Columns[0].Shares)
7272 }
7373 }
@@ -79,24 +79,24 @@ func TestResizeRowThree(t *testing.T) {
7979 w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}
8080 w.Apply("set-window-height +10%")
8181 s := w.Columns[0].Shares
82- if sum(s) != 100 || s[1] != 43 || s[0] < 10 || s[2] < 10 {
82+ if sum(s) != 10000 || s[1] != 4333 || s[0] < 1000 || s[2] < 1000 {
8383 t.Fatal(s)
8484 }
8585 for _, step := range []string{"+25%", "+25%", "-7%", "-100%", "+100%", "-3%"} {
8686 w.Apply(Action("set-window-height " + step))
8787 s := w.Columns[0].Shares
88- if sum(s) != 100 || slices.Min(s) < 10 {
88+ if sum(s) != 10000 || slices.Min(s) < 1000 {
8989 t.Fatal(step, s)
9090 }
9191 }
92- if s := w.Columns[0].Shares; s[1] != 77 {
92+ if s := w.Columns[0].Shares; s[1] != 7700 {
9393 t.Fatal(s)
9494 }
9595 // Proportional: the others shrink by their room above the minimum.
96- w.Columns[0].Shares = []int{50, 20, 30}
96+ w.Columns[0].Shares = []int{5000, 2000, 3000}
9797 w.Columns[0].Focus = 1
9898 w.Apply("set-window-height +20%")
99- if s := w.Columns[0].Shares; !slices.Equal(s, []int{37, 40, 23}) {
99+ if s := w.Columns[0].Shares; !slices.Equal(s, []int{3667, 4000, 2333}) {
100100 t.Fatal(s)
101101 }
102102 }
@@ -140,25 +140,25 @@ func TestSharesResetAndTravel(t *testing.T) {
140140 t.Fatal(w.Columns)
141141 }
142142 w.Apply("set-window-height +20%")
143- if !slices.Equal(w.Columns[0].Shares, []int{30, 70}) {
143+ if !slices.Equal(w.Columns[0].Shares, []int{3000, 7000}) {
144144 t.Fatal(w.Columns[0].Shares)
145145 }
146146 // Moving the window inside the column swaps the shares with it.
147147 w.Apply(ActionMoveWindowUp)
148- if !slices.Equal(w.Columns[0].Shares, []int{70, 30}) || w.Columns[0].Windows[0] != 2 {
148+ if !slices.Equal(w.Columns[0].Shares, []int{7000, 3000}) || w.Columns[0].Windows[0] != 2 {
149149 t.Fatal(w.Columns[0])
150150 }
151151 // The column takes its shares to another workspace.
152152 m.MoveToWorkspace(1, true)
153153 m.FocusNumber(2)
154- if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{70, 30}) {
154+ if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{7000, 3000}) {
155155 t.Fatal(c)
156156 }
157157 // MoveColumn keeps them.
158158 m.AddWindow(3)
159159 m.Current().FocusColumn(-1)
160160 m.Current().MoveColumn(1)
161- if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{70, 30}) {
161+ if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{7000, 3000}) {
162162 t.Fatal(m.Current().Columns)
163163 }
164164 // A window leaving resets the column to equal rows.
@@ -171,7 +171,7 @@ func TestSharesResetAndTravel(t *testing.T) {
171171 }
172172 }
173173 // A window joining invalidates them: equal rows.
174- w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{80, 20}}}
174+ w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{8000, 2000}}}
175175 w.Columns[0].Windows = append(w.Columns[0].Windows, 4)
176176 h := heights(w)
177177 if h[1] != h[3] {
@@ -182,7 +182,7 @@ func TestSharesResetAndTravel(t *testing.T) {
182182 func TestOverviewPreviewUsesShares(t *testing.T) {
183183 w := workspace()
184184 w.SetOutput(1000, 800)
185- w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}}
185+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{7000, 3000}}}
186186 tiles, _, _ := w.previewTiles()
187187 h := map[WindowID]int{}
188188 for _, p := range tiles {
@@ -29,15 +29,15 @@ func TestRowRectsEqualWithoutShares(t *testing.T) {
29 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {29 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
30 t.Fatal(rowRects(r, c, 5))30 t.Fatal(rowRects(r, c, 5))
31 }31 }
32- c.Shares = []int{50, 50} // wrong length: ignored32+ c.Shares = []int{5000, 5000} // wrong length: ignored
33 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {33 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
34 t.Fatal(rowRects(r, c, 5))34 t.Fatal(rowRects(r, c, 5))
35 }35 }
36- c.Shares = []int{50, 30, 30} // wrong sum: ignored36+ c.Shares = []int{5000, 3000, 3000} // wrong sum: ignored
37 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {37 if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
38 t.Fatal(rowRects(r, c, 5))38 t.Fatal(rowRects(r, c, 5))
39 }39 }
40- c.Shares = []int{50, 30, 20}40+ c.Shares = []int{5000, 3000, 2000}
41 rows := rowRects(r, c, 5)41 rows := rowRects(r, c, 5)
42 if rows[0].H != 46 || rows[1].H != 27 || rows[2].H != 93-46-27 || rows[2].Y+rows[2].H != 103 {42 if rows[0].H != 46 || rows[1].H != 27 || rows[2].H != 93-46-27 || rows[2].Y+rows[2].H != 103 {
43 t.Fatal(rows)43 t.Fatal(rows)
@@ -51,7 +51,7 @@ func TestResizeRow(t *testing.T) {
51 w.SetOutput(1000, 800)51 w.SetOutput(1000, 800)
52 w.Columns = []Column{{Windows: []WindowID{1, 2}}}52 w.Columns = []Column{{Windows: []WindowID{1, 2}}}
53 w.Apply("set-window-height +10%")53 w.Apply("set-window-height +10%")
54- if !slices.Equal(w.Columns[0].Shares, []int{60, 40}) {54+ if !slices.Equal(w.Columns[0].Shares, []int{6000, 4000}) {
55 t.Fatal(o, w.Columns[0].Shares)55 t.Fatal(o, w.Columns[0].Shares)
56 }56 }
57 h := heights(w)57 h := heights(w)
@@ -61,13 +61,13 @@ func TestResizeRow(t *testing.T) {
61 for range 10 {61 for range 10 {
62 w.Apply("set-window-height +10%")62 w.Apply("set-window-height +10%")
63 }63 }
64- if !slices.Equal(w.Columns[0].Shares, []int{90, 10}) {64+ if !slices.Equal(w.Columns[0].Shares, []int{9000, 1000}) {
65 t.Fatal(o, w.Columns[0].Shares)65 t.Fatal(o, w.Columns[0].Shares)
66 }66 }
67 for range 10 {67 for range 10 {
68 w.Apply("set-window-height -10%")68 w.Apply("set-window-height -10%")
69 }69 }
70- if !slices.Equal(w.Columns[0].Shares, []int{10, 90}) {70+ if !slices.Equal(w.Columns[0].Shares, []int{1000, 9000}) {
71 t.Fatal(o, w.Columns[0].Shares)71 t.Fatal(o, w.Columns[0].Shares)
72 }72 }
73 }73 }
@@ -79,24 +79,24 @@ func TestResizeRowThree(t *testing.T) {
79 w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}79 w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}
80 w.Apply("set-window-height +10%")80 w.Apply("set-window-height +10%")
81 s := w.Columns[0].Shares81 s := w.Columns[0].Shares
82- if sum(s) != 100 || s[1] != 43 || s[0] < 10 || s[2] < 10 {82+ if sum(s) != 10000 || s[1] != 4333 || s[0] < 1000 || s[2] < 1000 {
83 t.Fatal(s)83 t.Fatal(s)
84 }84 }
85 for _, step := range []string{"+25%", "+25%", "-7%", "-100%", "+100%", "-3%"} {85 for _, step := range []string{"+25%", "+25%", "-7%", "-100%", "+100%", "-3%"} {
86 w.Apply(Action("set-window-height " + step))86 w.Apply(Action("set-window-height " + step))
87 s := w.Columns[0].Shares87 s := w.Columns[0].Shares
88- if sum(s) != 100 || slices.Min(s) < 10 {88+ if sum(s) != 10000 || slices.Min(s) < 1000 {
89 t.Fatal(step, s)89 t.Fatal(step, s)
90 }90 }
91 }91 }
92- if s := w.Columns[0].Shares; s[1] != 77 {92+ if s := w.Columns[0].Shares; s[1] != 7700 {
93 t.Fatal(s)93 t.Fatal(s)
94 }94 }
95 // Proportional: the others shrink by their room above the minimum.95 // Proportional: the others shrink by their room above the minimum.
96- w.Columns[0].Shares = []int{50, 20, 30}96+ w.Columns[0].Shares = []int{5000, 2000, 3000}
97 w.Columns[0].Focus = 197 w.Columns[0].Focus = 1
98 w.Apply("set-window-height +20%")98 w.Apply("set-window-height +20%")
99- if s := w.Columns[0].Shares; !slices.Equal(s, []int{37, 40, 23}) {99+ if s := w.Columns[0].Shares; !slices.Equal(s, []int{3667, 4000, 2333}) {
100 t.Fatal(s)100 t.Fatal(s)
101 }101 }
102 }102 }
@@ -140,25 +140,25 @@ func TestSharesResetAndTravel(t *testing.T) {
140 t.Fatal(w.Columns)140 t.Fatal(w.Columns)
141 }141 }
142 w.Apply("set-window-height +20%")142 w.Apply("set-window-height +20%")
143- if !slices.Equal(w.Columns[0].Shares, []int{30, 70}) {143+ if !slices.Equal(w.Columns[0].Shares, []int{3000, 7000}) {
144 t.Fatal(w.Columns[0].Shares)144 t.Fatal(w.Columns[0].Shares)
145 }145 }
146 // Moving the window inside the column swaps the shares with it.146 // Moving the window inside the column swaps the shares with it.
147 w.Apply(ActionMoveWindowUp)147 w.Apply(ActionMoveWindowUp)
148- if !slices.Equal(w.Columns[0].Shares, []int{70, 30}) || w.Columns[0].Windows[0] != 2 {148+ if !slices.Equal(w.Columns[0].Shares, []int{7000, 3000}) || w.Columns[0].Windows[0] != 2 {
149 t.Fatal(w.Columns[0])149 t.Fatal(w.Columns[0])
150 }150 }
151 // The column takes its shares to another workspace.151 // The column takes its shares to another workspace.
152 m.MoveToWorkspace(1, true)152 m.MoveToWorkspace(1, true)
153 m.FocusNumber(2)153 m.FocusNumber(2)
154- if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{70, 30}) {154+ if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{7000, 3000}) {
155 t.Fatal(c)155 t.Fatal(c)
156 }156 }
157 // MoveColumn keeps them.157 // MoveColumn keeps them.
158 m.AddWindow(3)158 m.AddWindow(3)
159 m.Current().FocusColumn(-1)159 m.Current().FocusColumn(-1)
160 m.Current().MoveColumn(1)160 m.Current().MoveColumn(1)
161- if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{70, 30}) {161+ if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{7000, 3000}) {
162 t.Fatal(m.Current().Columns)162 t.Fatal(m.Current().Columns)
163 }163 }
164 // A window leaving resets the column to equal rows.164 // A window leaving resets the column to equal rows.
@@ -171,7 +171,7 @@ func TestSharesResetAndTravel(t *testing.T) {
171 }171 }
172 }172 }
173 // A window joining invalidates them: equal rows.173 // A window joining invalidates them: equal rows.
174- w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{80, 20}}}174+ w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{8000, 2000}}}
175 w.Columns[0].Windows = append(w.Columns[0].Windows, 4)175 w.Columns[0].Windows = append(w.Columns[0].Windows, 4)
176 h := heights(w)176 h := heights(w)
177 if h[1] != h[3] {177 if h[1] != h[3] {
@@ -182,7 +182,7 @@ func TestSharesResetAndTravel(t *testing.T) {
182 func TestOverviewPreviewUsesShares(t *testing.T) {182 func TestOverviewPreviewUsesShares(t *testing.T) {
183 w := workspace()183 w := workspace()
184 w.SetOutput(1000, 800)184 w.SetOutput(1000, 800)
185- w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}}185+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{7000, 3000}}}
186 tiles, _, _ := w.previewTiles()186 tiles, _, _ := w.previewTiles()
187 h := map[WindowID]int{}187 h := map[WindowID]int{}
188 for _, p := range tiles {188 for _, p := range tiles {
M internal/core/security.go +3 -0
@@ -35,6 +35,9 @@ func (c *Core) syncSecurity() bool {
3535 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
3636 // Wayland cancels its own drag when the epoch changes.
3737 c.dataDrag = nil
38+ // The adapter clears cursor overrides when protection starts; send nothing.
39+ c.pgrab = nil
40+ c.lastBorderClick.ws = nil
3841 c.pointerAt, c.motionTime = [2]float64{}, 0
3942 c.keyboard = keyboard{}
4043 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}
@@ -35,6 +35,9 @@ func (c *Core) syncSecurity() bool {
35 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 035 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
36 // Wayland cancels its own drag when the epoch changes.36 // Wayland cancels its own drag when the epoch changes.
37 c.dataDrag = nil37 c.dataDrag = nil
38+ // The adapter clears cursor overrides when protection starts; send nothing.
39+ c.pgrab = nil
40+ c.lastBorderClick.ws = nil
38 c.pointerAt, c.motionTime = [2]float64{}, 041 c.pointerAt, c.motionTime = [2]float64{}, 0
39 c.keyboard = keyboard{}42 c.keyboard = keyboard{}
40 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}43 c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{}
A internal/core/spans_test.go +203 -0
@@ -0,0 +1,203 @@
1+package core
2+
3+import "testing"
4+
5+func spanWorkspace(mode Overflow, n int) *Workspace {
6+ w := workspace()
7+ w.Overflow = mode
8+ w.MaxColumns = 3
9+ w.SetOutput(1000, 800)
10+ w.SetGaps(10)
11+ for i := 0; i < n; i++ {
12+ w.AddWindow(WindowID(i + 1))
13+ }
14+ w.View = 0
15+ w.view = slide{}
16+ return w
17+}
18+
19+func stampSpans(w *Workspace, first, last int) {
20+ w.spanSeq++
21+ for i := first; i <= last; i++ {
22+ w.Columns[i].Span = 10000 / (last - first + 1)
23+ w.Columns[i].spanStamp = w.spanSeq
24+ }
25+ w.Columns[last].Span += 10000 % (last - first + 1)
26+}
27+
28+func TestSpansMutations(t *testing.T) {
29+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
30+ for _, mutation := range []string{"add", "remove", "take", "receive", "swap", "grow", "shrink", "action"} {
31+ t.Run(string(mode)+"/"+mutation, func(t *testing.T) {
32+ w := spanWorkspace(mode, 2)
33+ stampSpans(w, 0, 1)
34+ switch mutation {
35+ case "add":
36+ w.AddWindow(3)
37+ case "remove":
38+ w.RemoveWindow(1)
39+ case "take":
40+ col, ok := w.takeColumn()
41+ if !ok {
42+ t.Fatal("take")
43+ }
44+ other := spanWorkspace(mode, 1)
45+ other.receive(col, -1)
46+ for _, c := range other.Columns {
47+ if c.Span != 0 {
48+ t.Fatal("destination kept stale span")
49+ }
50+ }
51+ case "receive":
52+ w.receive(Column{Windows: []WindowID{3}}, 1)
53+ case "swap":
54+ w.Focus = 0
55+ w.MoveColumn(1)
56+ case "grow":
57+ w.GrowColumn(false)
58+ case "shrink":
59+ w.ShrinkColumn()
60+ case "action":
61+ w.Apply("set-column-width +1")
62+ }
63+ for _, c := range w.Columns {
64+ if mutation == "swap" {
65+ if c.Span == 0 {
66+ t.Fatal("swap discarded valid spans")
67+ }
68+ } else if c.Span != 0 || c.spanStamp != 0 {
69+ t.Fatal("stale span", c)
70+ }
71+ }
72+ })
73+ }
74+ }
75+}
76+
77+func TestSpansCascadeIsolation(t *testing.T) {
78+ w := spanWorkspace(OverflowCascade, 5)
79+ stampSpans(w, 0, 2)
80+ stampSpans(w, 3, 4)
81+ w.RemoveWindow(w.Columns[0].Windows[0])
82+ if w.Columns[0].Span != 0 || w.Columns[1].Span != 0 {
83+ t.Fatal("changed band retained spans")
84+ }
85+ if w.Columns[2].Span != 5000 || w.Columns[3].Span != 5000 {
86+ t.Fatal("unrelated band reset")
87+ }
88+}
89+
90+func TestSpanWidths(t *testing.T) {
91+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
92+ w := spanWorkspace(mode, 2)
93+ stampSpans(w, 0, 1)
94+ w.Columns[0].Span = 3000
95+ w.Columns[1].Span = 7000
96+ rs := w.columnRects()
97+ avail := 970
98+ if mode == OverflowScroll {
99+ avail = 980
100+ }
101+ if rs[0].W != avail*3/10 {
102+ t.Fatal(mode, rs)
103+ }
104+ w.SetOutput(1400, 800)
105+ if w.columnRects()[0].W != (avail+400)*3/10 {
106+ t.Fatal("ratio lost", mode, w.columnRects())
107+ }
108+ w.Columns[0].FullWidth = true
109+ if w.columnWidth(0) != 1380 {
110+ t.Fatal("special width lost")
111+ }
112+ }
113+ w := spanWorkspace(OverflowFixed, 4)
114+ stampSpans(w, 0, 3)
115+ w.scroll()
116+ for _, c := range w.Columns {
117+ if c.Span != 0 {
118+ t.Fatal("spiral spans")
119+ }
120+ }
121+}
122+
123+func TestSpansDropIntoLane(t *testing.T) {
124+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
125+ w := spanWorkspace(mode, 2)
126+ stampSpans(w, 0, 1)
127+ src := spanWorkspace(mode, 1)
128+ src.Columns[0].Windows[0] = 9
129+ m := monitor()
130+ m.Workspaces = []*Workspace{w, src}
131+ sc := &screen{mon: m}
132+ c := &Core{screens: []*screen{sc}}
133+ c.drop(9, dropTarget{kind: dropInsert, screen: sc, ws: w, anchor: w.Columns[1].Windows[0]})
134+ if len(w.Columns) != 3 {
135+ t.Fatal("not dropped", mode, w.Columns)
136+ }
137+ for _, col := range w.Columns {
138+ if col.Span != 0 || col.spanStamp != 0 {
139+ t.Fatal("drop retained stamp", mode, col)
140+ }
141+ }
142+ }
143+}
144+
145+func TestSpanLaneIdentity(t *testing.T) {
146+ for _, action := range []string{"cross-drop", "within-drop", "within-keyboard"} {
147+ t.Run(action, func(t *testing.T) {
148+ w := spanWorkspace(OverflowCascade, 4)
149+ w.SetMaxColumns(2)
150+ for i := range w.Columns {
151+ w.Columns[i].BandStart = i%2 == 0
152+ }
153+ for _, i := range []int{0, 2} {
154+ rs := w.columnRects()
155+ w.setBorder(border{kind: borderColumn, col: i}, rs[i].X+rs[i].W+5)
156+ }
157+ for _, col := range w.Columns {
158+ if col.Span != 5000 {
159+ t.Fatal("not stamped equally", w.Columns)
160+ }
161+ }
162+ cross := action == "cross-drop"
163+ if action == "cross-drop" || action == "within-drop" {
164+ m := monitor()
165+ m.Workspaces = []*Workspace{w}
166+ sc := &screen{mon: m}
167+ c := &Core{screens: []*screen{sc}}
168+ to := 1
169+ if cross {
170+ to = 2
171+ }
172+ c.drop(w.Columns[0].Windows[0], dropTarget{kind: dropSwap, screen: sc, ws: w, anchor: w.Columns[to].Windows[0]})
173+ } else {
174+ w.Focus = 0
175+ w.MoveColumn(1)
176+ }
177+ w.dropStaleSpans()
178+ for _, col := range w.Columns {
179+ if cross && col.Span != 0 {
180+ t.Fatal("cross-band swap retained stamp", w.Columns)
181+ }
182+ if !cross && col.Span != 5000 {
183+ t.Fatal("within-band swap discarded stamp", w.Columns)
184+ }
185+ }
186+ })
187+ }
188+}
189+
190+// A column arriving from another workspace carries that workspace's stamp,
191+// which may collide with a stamp issued here.
192+func TestSpansForeignStampCleared(t *testing.T) {
193+ w := spanWorkspace(OverflowScroll, 2)
194+ stampSpans(w, 0, 1)
195+ stamp := w.spanSeq
196+ w.receive(Column{Windows: []WindowID{9}, Span: 3000, spanStamp: stamp}, -1)
197+ w.dropStaleSpans()
198+ for _, col := range w.Columns {
199+ if col.Span != 0 || col.spanStamp != 0 {
200+ t.Fatal("foreign stamp joined the lane", w.Columns)
201+ }
202+ }
203+}
@@ -0,0 +1,203 @@
1+package core
2+
3+import "testing"
4+
5+func spanWorkspace(mode Overflow, n int) *Workspace {
6+ w := workspace()
7+ w.Overflow = mode
8+ w.MaxColumns = 3
9+ w.SetOutput(1000, 800)
10+ w.SetGaps(10)
11+ for i := 0; i < n; i++ {
12+ w.AddWindow(WindowID(i + 1))
13+ }
14+ w.View = 0
15+ w.view = slide{}
16+ return w
17+}
18+
19+func stampSpans(w *Workspace, first, last int) {
20+ w.spanSeq++
21+ for i := first; i <= last; i++ {
22+ w.Columns[i].Span = 10000 / (last - first + 1)
23+ w.Columns[i].spanStamp = w.spanSeq
24+ }
25+ w.Columns[last].Span += 10000 % (last - first + 1)
26+}
27+
28+func TestSpansMutations(t *testing.T) {
29+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
30+ for _, mutation := range []string{"add", "remove", "take", "receive", "swap", "grow", "shrink", "action"} {
31+ t.Run(string(mode)+"/"+mutation, func(t *testing.T) {
32+ w := spanWorkspace(mode, 2)
33+ stampSpans(w, 0, 1)
34+ switch mutation {
35+ case "add":
36+ w.AddWindow(3)
37+ case "remove":
38+ w.RemoveWindow(1)
39+ case "take":
40+ col, ok := w.takeColumn()
41+ if !ok {
42+ t.Fatal("take")
43+ }
44+ other := spanWorkspace(mode, 1)
45+ other.receive(col, -1)
46+ for _, c := range other.Columns {
47+ if c.Span != 0 {
48+ t.Fatal("destination kept stale span")
49+ }
50+ }
51+ case "receive":
52+ w.receive(Column{Windows: []WindowID{3}}, 1)
53+ case "swap":
54+ w.Focus = 0
55+ w.MoveColumn(1)
56+ case "grow":
57+ w.GrowColumn(false)
58+ case "shrink":
59+ w.ShrinkColumn()
60+ case "action":
61+ w.Apply("set-column-width +1")
62+ }
63+ for _, c := range w.Columns {
64+ if mutation == "swap" {
65+ if c.Span == 0 {
66+ t.Fatal("swap discarded valid spans")
67+ }
68+ } else if c.Span != 0 || c.spanStamp != 0 {
69+ t.Fatal("stale span", c)
70+ }
71+ }
72+ })
73+ }
74+ }
75+}
76+
77+func TestSpansCascadeIsolation(t *testing.T) {
78+ w := spanWorkspace(OverflowCascade, 5)
79+ stampSpans(w, 0, 2)
80+ stampSpans(w, 3, 4)
81+ w.RemoveWindow(w.Columns[0].Windows[0])
82+ if w.Columns[0].Span != 0 || w.Columns[1].Span != 0 {
83+ t.Fatal("changed band retained spans")
84+ }
85+ if w.Columns[2].Span != 5000 || w.Columns[3].Span != 5000 {
86+ t.Fatal("unrelated band reset")
87+ }
88+}
89+
90+func TestSpanWidths(t *testing.T) {
91+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
92+ w := spanWorkspace(mode, 2)
93+ stampSpans(w, 0, 1)
94+ w.Columns[0].Span = 3000
95+ w.Columns[1].Span = 7000
96+ rs := w.columnRects()
97+ avail := 970
98+ if mode == OverflowScroll {
99+ avail = 980
100+ }
101+ if rs[0].W != avail*3/10 {
102+ t.Fatal(mode, rs)
103+ }
104+ w.SetOutput(1400, 800)
105+ if w.columnRects()[0].W != (avail+400)*3/10 {
106+ t.Fatal("ratio lost", mode, w.columnRects())
107+ }
108+ w.Columns[0].FullWidth = true
109+ if w.columnWidth(0) != 1380 {
110+ t.Fatal("special width lost")
111+ }
112+ }
113+ w := spanWorkspace(OverflowFixed, 4)
114+ stampSpans(w, 0, 3)
115+ w.scroll()
116+ for _, c := range w.Columns {
117+ if c.Span != 0 {
118+ t.Fatal("spiral spans")
119+ }
120+ }
121+}
122+
123+func TestSpansDropIntoLane(t *testing.T) {
124+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
125+ w := spanWorkspace(mode, 2)
126+ stampSpans(w, 0, 1)
127+ src := spanWorkspace(mode, 1)
128+ src.Columns[0].Windows[0] = 9
129+ m := monitor()
130+ m.Workspaces = []*Workspace{w, src}
131+ sc := &screen{mon: m}
132+ c := &Core{screens: []*screen{sc}}
133+ c.drop(9, dropTarget{kind: dropInsert, screen: sc, ws: w, anchor: w.Columns[1].Windows[0]})
134+ if len(w.Columns) != 3 {
135+ t.Fatal("not dropped", mode, w.Columns)
136+ }
137+ for _, col := range w.Columns {
138+ if col.Span != 0 || col.spanStamp != 0 {
139+ t.Fatal("drop retained stamp", mode, col)
140+ }
141+ }
142+ }
143+}
144+
145+func TestSpanLaneIdentity(t *testing.T) {
146+ for _, action := range []string{"cross-drop", "within-drop", "within-keyboard"} {
147+ t.Run(action, func(t *testing.T) {
148+ w := spanWorkspace(OverflowCascade, 4)
149+ w.SetMaxColumns(2)
150+ for i := range w.Columns {
151+ w.Columns[i].BandStart = i%2 == 0
152+ }
153+ for _, i := range []int{0, 2} {
154+ rs := w.columnRects()
155+ w.setBorder(border{kind: borderColumn, col: i}, rs[i].X+rs[i].W+5)
156+ }
157+ for _, col := range w.Columns {
158+ if col.Span != 5000 {
159+ t.Fatal("not stamped equally", w.Columns)
160+ }
161+ }
162+ cross := action == "cross-drop"
163+ if action == "cross-drop" || action == "within-drop" {
164+ m := monitor()
165+ m.Workspaces = []*Workspace{w}
166+ sc := &screen{mon: m}
167+ c := &Core{screens: []*screen{sc}}
168+ to := 1
169+ if cross {
170+ to = 2
171+ }
172+ c.drop(w.Columns[0].Windows[0], dropTarget{kind: dropSwap, screen: sc, ws: w, anchor: w.Columns[to].Windows[0]})
173+ } else {
174+ w.Focus = 0
175+ w.MoveColumn(1)
176+ }
177+ w.dropStaleSpans()
178+ for _, col := range w.Columns {
179+ if cross && col.Span != 0 {
180+ t.Fatal("cross-band swap retained stamp", w.Columns)
181+ }
182+ if !cross && col.Span != 5000 {
183+ t.Fatal("within-band swap discarded stamp", w.Columns)
184+ }
185+ }
186+ })
187+ }
188+}
189+
190+// A column arriving from another workspace carries that workspace's stamp,
191+// which may collide with a stamp issued here.
192+func TestSpansForeignStampCleared(t *testing.T) {
193+ w := spanWorkspace(OverflowScroll, 2)
194+ stampSpans(w, 0, 1)
195+ stamp := w.spanSeq
196+ w.receive(Column{Windows: []WindowID{9}, Span: 3000, spanStamp: stamp}, -1)
197+ w.dropStaleSpans()
198+ for _, col := range w.Columns {
199+ if col.Span != 0 || col.spanStamp != 0 {
200+ t.Fatal("foreign stamp joined the lane", w.Columns)
201+ }
202+ }
203+}
M internal/core/switcher.go +1 -1
@@ -239,7 +239,7 @@ func (c *Core) switchStep(dir int) {
239239 // screen: start over.
240240 c.cancelSwitcher()
241241 }
242- if sc.mon.ov.open || c.drag != nil {
242+ if sc.mon.ov.open || c.pointerBusy() {
243243 return
244244 }
245245 m := sc.mon
@@ -239,7 +239,7 @@ func (c *Core) switchStep(dir int) {
239 // screen: start over.239 // screen: start over.
240 c.cancelSwitcher()240 c.cancelSwitcher()
241 }241 }
242- if sc.mon.ov.open || c.drag != nil {242+ if sc.mon.ov.open || c.pointerBusy() {
243 return243 return
244 }244 }
245 m := sc.mon245 m := sc.mon
M internal/ports/client_commands.go +7 -0
@@ -297,3 +297,10 @@ type SetModifiers struct {
297297 }
298298
299299 func (SetModifiers) clientCommand() {}
300+
301+// CursorOverride carries core -> wayland the cursor a compositor pointer
302+// interaction (border resize, future column move) shows instead of the
303+// client's: a CSS cursor name; "" clears it.
304+type CursorOverride struct{ Shape string }
305+
306+func (CursorOverride) clientCommand() {}
@@ -297,3 +297,10 @@ type SetModifiers struct {
297 }297 }
298 298
299 func (SetModifiers) clientCommand() {}299 func (SetModifiers) clientCommand() {}
300+
301+// CursorOverride carries core -> wayland the cursor a compositor pointer
302+// interaction (border resize, future column move) shows instead of the
303+// client's: a CSS cursor name; "" clears it.
304+type CursorOverride struct{ Shape string }
305+
306+func (CursorOverride) clientCommand() {}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close