detached 8cf90bb

feat(core): resize tiled borders with compositor pointer grabs

commit
8cf90bbc02b31cb114aaa0df00dc5be2ededd2bb
author
brice · · 2026-10-21 05:37 UTC
committer
brice · · 2026-10-21 07:11 UTC
parent
02f2bf5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +485 -11
A internal/core/border_resize.go +90 -0
@@ -0,0 +1,90 @@
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+ start [2]float64
19+}
20+
21+func newBorderResize(c *Core, s *screen, w *Workspace, b border, button uint32) *borderResize {
22+ g := &borderResize{core: c, screen: s, ws: w, border: b, btn: button, start: [2]float64{c.cursorX, c.cursorY}, saved: slices.Clone(w.Columns)}
23+ for i := range g.saved {
24+ g.saved[i].Windows = slices.Clone(g.saved[i].Windows)
25+ g.saved[i].Shares = slices.Clone(g.saved[i].Shares)
26+ }
27+ c.focusScreen = c.screenIndex(s.name())
28+ return g
29+}
30+
31+func (g *borderResize) button() uint32 { return g.btn }
32+func (g *borderResize) cursor() string {
33+ if g.border.kind == borderColumn {
34+ return "ew-resize"
35+ }
36+ return "ns-resize"
37+}
38+
39+func (g *borderResize) valid() bool {
40+ c, s, w := g.core, g.screen, g.ws
41+ if !slices.Contains(c.screens, s) || s.mon.Current() != w || s.mon.previewing() || s.mon.ov.open || w.pinned() || len(w.Columns) != len(g.saved) {
42+ return false
43+ }
44+ for i, col := range w.Columns {
45+ if !slices.Equal(col.Windows, g.saved[i].Windows) {
46+ return false
47+ }
48+ }
49+ b := g.border
50+ if b.col < 0 || b.col >= len(w.Columns) {
51+ return false
52+ }
53+ if b.kind == borderRow {
54+ return b.row >= 0 && b.row+1 < len(w.Columns[b.col].Windows)
55+ }
56+ return b.col+1 < len(w.Columns) && w.laneOf(b.col) == w.laneOf(b.col+1)
57+}
58+
59+func (g *borderResize) motion(ctx context.Context, _ time.Duration) error {
60+ c := g.core
61+ if !g.valid() {
62+ if err := c.abortPointerGrab(ctx); err != nil {
63+ return err
64+ }
65+ return c.publish(ctx)
66+ }
67+ pos := int(c.cursorX) - g.screen.x
68+ if g.border.kind == borderRow {
69+ pos = int(c.cursorY) - g.screen.y
70+ }
71+ g.ws.setBorder(g.border, pos)
72+ return c.publish(ctx)
73+}
74+
75+func (g *borderResize) release(ctx context.Context) error {
76+ if err := g.core.publish(ctx); err != nil {
77+ return err
78+ }
79+ return g.core.rehit(ctx)
80+}
81+
82+func (g *borderResize) cancel() {
83+ if !g.valid() {
84+ return
85+ }
86+ for i, saved := range g.saved {
87+ col := &g.ws.Columns[i]
88+ col.Span, col.spanLane, col.Shares = saved.Span, saved.spanLane, slices.Clone(saved.Shares)
89+ }
90+}
@@ -0,0 +1,90 @@
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+ start [2]float64
19+}
20+
21+func newBorderResize(c *Core, s *screen, w *Workspace, b border, button uint32) *borderResize {
22+ g := &borderResize{core: c, screen: s, ws: w, border: b, btn: button, start: [2]float64{c.cursorX, c.cursorY}, saved: slices.Clone(w.Columns)}
23+ for i := range g.saved {
24+ g.saved[i].Windows = slices.Clone(g.saved[i].Windows)
25+ g.saved[i].Shares = slices.Clone(g.saved[i].Shares)
26+ }
27+ c.focusScreen = c.screenIndex(s.name())
28+ return g
29+}
30+
31+func (g *borderResize) button() uint32 { return g.btn }
32+func (g *borderResize) cursor() string {
33+ if g.border.kind == borderColumn {
34+ return "ew-resize"
35+ }
36+ return "ns-resize"
37+}
38+
39+func (g *borderResize) valid() bool {
40+ c, s, w := g.core, g.screen, g.ws
41+ if !slices.Contains(c.screens, s) || s.mon.Current() != w || s.mon.previewing() || s.mon.ov.open || w.pinned() || len(w.Columns) != len(g.saved) {
42+ return false
43+ }
44+ for i, col := range w.Columns {
45+ if !slices.Equal(col.Windows, g.saved[i].Windows) {
46+ return false
47+ }
48+ }
49+ b := g.border
50+ if b.col < 0 || b.col >= len(w.Columns) {
51+ return false
52+ }
53+ if b.kind == borderRow {
54+ return b.row >= 0 && b.row+1 < len(w.Columns[b.col].Windows)
55+ }
56+ return b.col+1 < len(w.Columns) && w.laneOf(b.col) == w.laneOf(b.col+1)
57+}
58+
59+func (g *borderResize) motion(ctx context.Context, _ time.Duration) error {
60+ c := g.core
61+ if !g.valid() {
62+ if err := c.abortPointerGrab(ctx); err != nil {
63+ return err
64+ }
65+ return c.publish(ctx)
66+ }
67+ pos := int(c.cursorX) - g.screen.x
68+ if g.border.kind == borderRow {
69+ pos = int(c.cursorY) - g.screen.y
70+ }
71+ g.ws.setBorder(g.border, pos)
72+ return c.publish(ctx)
73+}
74+
75+func (g *borderResize) release(ctx context.Context) error {
76+ if err := g.core.publish(ctx); err != nil {
77+ return err
78+ }
79+ return g.core.rehit(ctx)
80+}
81+
82+func (g *borderResize) cancel() {
83+ if !g.valid() {
84+ return
85+ }
86+ for i, saved := range g.saved {
87+ col := &g.ws.Columns[i]
88+ col.Span, col.spanLane, col.Shares = saved.Span, saved.spanLane, slices.Clone(saved.Shares)
89+ }
90+}
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 g, ok := c.pgrab.(*borderResize); ok && !g.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 g, ok := c.pgrab.(*borderResize); ok && !g.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/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
@@ -631,6 +637,11 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
631637 sc.mon.Current().noteFocus()
632638 }
633639 }
640+ if g, ok := c.pgrab.(*borderResize); ok && !g.valid() {
641+ if err := c.abortPointerGrab(ctx); err != nil {
642+ return err
643+ }
644+ }
634645 c.refreshShown()
635646 c.captureExpire()
636647 capture, err := c.captureEvaluate(ctx)
@@ -752,7 +763,7 @@ func (c *Core) rehit(ctx context.Context) error {
752763 // The drag target follows the layout too.
753764 return c.dataDragRetarget(ctx, c.motionTime)
754765 }
755- if c.grab != 0 || c.drag != nil {
766+ if c.grab != 0 || c.pointerBusy() {
756767 return nil
757768 }
758769 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
@@ -631,6 +637,11 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
631 sc.mon.Current().noteFocus()637 sc.mon.Current().noteFocus()
632 }638 }
633 }639 }
640+ if g, ok := c.pgrab.(*borderResize); ok && !g.valid() {
641+ if err := c.abortPointerGrab(ctx); err != nil {
642+ return err
643+ }
644+ }
634 c.refreshShown()645 c.refreshShown()
635 c.captureExpire()646 c.captureExpire()
636 capture, err := c.captureEvaluate(ctx)647 capture, err := c.captureEvaluate(ctx)
@@ -752,7 +763,7 @@ func (c *Core) rehit(ctx context.Context) error {
752 // The drag target follows the layout too.763 // The drag target follows the layout too.
753 return c.dataDragRetarget(ctx, c.motionTime)764 return c.dataDragRetarget(ctx, c.motionTime)
754 }765 }
755- if c.grab != 0 || c.drag != nil {766+ if c.grab != 0 || c.pointerBusy() {
756 return nil767 return nil
757 }768 }
758 id, x, y := c.hit(c.cursorX, c.cursorY)769 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
A internal/core/pointergrab.go +93 -0
@@ -0,0 +1,93 @@
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+ button() uint32
16+ motion(ctx context.Context, at time.Duration) error
17+ release(ctx context.Context) error
18+ cancel() // Escape: restore the state from before the grab
19+ cursor() string // CSS cursor name; "" = the client's cursor
20+}
21+
22+func (c *Core) pointerBusy() bool { return c.drag != nil || c.pgrab != nil }
23+
24+func (c *Core) startPointerGrab(ctx context.Context, v ports.PointerButton) (bool, error) {
25+ 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()) {
26+ return false, nil
27+ }
28+ if v.Button == btnRight {
29+ if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 {
30+ s := c.screens[i]
31+ w := s.mon.Current()
32+ lx, ly := int(c.cursorX)-s.x, int(c.cursorY)-s.y
33+ if !s.mon.previewing() && !s.mon.ov.open && !w.pinned() && s.mon.frameHas(float64(lx), float64(ly)) {
34+ if b, ok := w.borderAt(lx, ly, max(w.gap()/2, gapPad)); ok {
35+ last := c.lastBorderClick
36+ 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 {
37+ c.lastBorderClick.ws = nil
38+ now := c.now()
39+ shots := c.snapshot(now)
40+ w.resetBorder(b)
41+ c.transition(shots, now)
42+ c.buttons[v.Button], c.swallow[v.Button] = true, true
43+ return true, c.publish(ctx)
44+ }
45+ c.lastBorderClick.at, c.lastBorderClick.ws, c.lastBorderClick.b = v.Time, w, b
46+ g := newBorderResize(c, s, w, b, v.Button)
47+ return true, c.beginPointerGrab(ctx, g)
48+ }
49+ }
50+ }
51+ }
52+ c.lastBorderClick.ws = nil
53+ return c.startDrag(ctx, v)
54+}
55+
56+func (c *Core) beginPointerGrab(ctx context.Context, g pointerGrab) error {
57+ if err := c.dismissGrabs(ctx, 0); err != nil {
58+ return err
59+ }
60+ c.pgrab = g
61+ c.buttons[g.button()] = true
62+ c.grab = 0
63+ if c.pointer != 0 {
64+ c.pointer = 0
65+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
66+ return err
67+ }
68+ }
69+ if err := c.command(ctx, ports.CursorOverride{Shape: g.cursor()}); err != nil {
70+ return err
71+ }
72+ return c.publish(ctx)
73+}
74+
75+func (c *Core) endPointerGrab(ctx context.Context) error {
76+ g := c.pgrab
77+ c.pgrab = nil
78+ if err := c.command(ctx, ports.CursorOverride{}); err != nil {
79+ return err
80+ }
81+ return g.release(ctx)
82+}
83+
84+// abortPointerGrab keeps the current layout and swallows the held release.
85+// The caller restores state first when this is an Escape cancellation.
86+func (c *Core) abortPointerGrab(ctx context.Context) error {
87+ if c.pgrab == nil {
88+ return nil
89+ }
90+ c.swallow[c.pgrab.button()] = true
91+ c.pgrab = nil
92+ return c.command(ctx, ports.CursorOverride{})
93+}
@@ -0,0 +1,93 @@
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+ button() uint32
16+ motion(ctx context.Context, at time.Duration) error
17+ release(ctx context.Context) error
18+ cancel() // Escape: restore the state from before the grab
19+ cursor() string // CSS cursor name; "" = the client's cursor
20+}
21+
22+func (c *Core) pointerBusy() bool { return c.drag != nil || c.pgrab != nil }
23+
24+func (c *Core) startPointerGrab(ctx context.Context, v ports.PointerButton) (bool, error) {
25+ 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()) {
26+ return false, nil
27+ }
28+ if v.Button == btnRight {
29+ if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 {
30+ s := c.screens[i]
31+ w := s.mon.Current()
32+ lx, ly := int(c.cursorX)-s.x, int(c.cursorY)-s.y
33+ if !s.mon.previewing() && !s.mon.ov.open && !w.pinned() && s.mon.frameHas(float64(lx), float64(ly)) {
34+ if b, ok := w.borderAt(lx, ly, max(w.gap()/2, gapPad)); ok {
35+ last := c.lastBorderClick
36+ 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 {
37+ c.lastBorderClick.ws = nil
38+ now := c.now()
39+ shots := c.snapshot(now)
40+ w.resetBorder(b)
41+ c.transition(shots, now)
42+ c.buttons[v.Button], c.swallow[v.Button] = true, true
43+ return true, c.publish(ctx)
44+ }
45+ c.lastBorderClick.at, c.lastBorderClick.ws, c.lastBorderClick.b = v.Time, w, b
46+ g := newBorderResize(c, s, w, b, v.Button)
47+ return true, c.beginPointerGrab(ctx, g)
48+ }
49+ }
50+ }
51+ }
52+ c.lastBorderClick.ws = nil
53+ return c.startDrag(ctx, v)
54+}
55+
56+func (c *Core) beginPointerGrab(ctx context.Context, g pointerGrab) error {
57+ if err := c.dismissGrabs(ctx, 0); err != nil {
58+ return err
59+ }
60+ c.pgrab = g
61+ c.buttons[g.button()] = true
62+ c.grab = 0
63+ if c.pointer != 0 {
64+ c.pointer = 0
65+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
66+ return err
67+ }
68+ }
69+ if err := c.command(ctx, ports.CursorOverride{Shape: g.cursor()}); err != nil {
70+ return err
71+ }
72+ return c.publish(ctx)
73+}
74+
75+func (c *Core) endPointerGrab(ctx context.Context) error {
76+ g := c.pgrab
77+ c.pgrab = nil
78+ if err := c.command(ctx, ports.CursorOverride{}); err != nil {
79+ return err
80+ }
81+ return g.release(ctx)
82+}
83+
84+// abortPointerGrab keeps the current layout and swallows the held release.
85+// The caller restores state first when this is an Escape cancellation.
86+func (c *Core) abortPointerGrab(ctx context.Context) error {
87+ if c.pgrab == nil {
88+ return nil
89+ }
90+ c.swallow[c.pgrab.button()] = true
91+ c.pgrab = nil
92+ return c.command(ctx, ports.CursorOverride{})
93+}
A internal/core/pointergrab_test.go +252 -0
@@ -0,0 +1,252 @@
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+}
@@ -0,0 +1,252 @@
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+}
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{}
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

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