detached 5ebaa37

merge: pointer-games (relative pointer, pointer lock and confine)

commit
5ebaa37b02175d3c7b765cf29028585e7a931078
author
brice · · 2026-09-25 12:49 UTC
merge of
188b9af + 7535410 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +859 -37
M internal/adapters/libinput/forward.go +6 -1
@@ -35,7 +35,12 @@ func (f *forwarder) push(ev ports.InputEvent) {
3535 if m, ok := ev.(ports.PointerMotion); ok {
3636 f.stats.Motions++
3737 if n := len(f.queue); n > 0 {
38- if _, tail := f.queue[n-1].(ports.PointerMotion); tail {
38+ if prev, tail := f.queue[n-1].(ports.PointerMotion); tail {
39+ // Relative deltas add up: games read them, not the position.
40+ m.DX += prev.DX
41+ m.DY += prev.DY
42+ m.UnaccelDX += prev.UnaccelDX
43+ m.UnaccelDY += prev.UnaccelDY
3944 f.queue[n-1] = m
4045 f.stats.Coalesced++
4146 f.mu.Unlock()
@@ -35,7 +35,12 @@ func (f *forwarder) push(ev ports.InputEvent) {
35 if m, ok := ev.(ports.PointerMotion); ok {35 if m, ok := ev.(ports.PointerMotion); ok {
36 f.stats.Motions++36 f.stats.Motions++
37 if n := len(f.queue); n > 0 {37 if n := len(f.queue); n > 0 {
38- if _, tail := f.queue[n-1].(ports.PointerMotion); tail {38+ if prev, tail := f.queue[n-1].(ports.PointerMotion); tail {
39+ // Relative deltas add up: games read them, not the position.
40+ m.DX += prev.DX
41+ m.DY += prev.DY
42+ m.UnaccelDX += prev.UnaccelDX
43+ m.UnaccelDY += prev.UnaccelDY
39 f.queue[n-1] = m44 f.queue[n-1] = m
40 f.stats.Coalesced++45 f.stats.Coalesced++
41 f.mu.Unlock()46 f.mu.Unlock()
M internal/adapters/libinput/forward_test.go +11 -0
@@ -68,3 +68,14 @@ func TestForwarderStopsOnCancel(t *testing.T) {
6868 t.Fatal("run did not stop while blocked on send")
6969 }
7070 }
71+
72+// Coalesced motions keep the sum of their relative deltas.
73+func TestForwarderSumsDeltas(t *testing.T) {
74+ f := newForwarder()
75+ f.push(ports.PointerMotion{X: 1, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4})
76+ f.push(ports.PointerMotion{X: 2, DX: 10, DY: 20, UnaccelDX: 30, UnaccelDY: 40})
77+ ev, _ := f.pop()
78+ if want := (ports.PointerMotion{X: 2, DX: 11, DY: 22, UnaccelDX: 33, UnaccelDY: 44}); ev != want {
79+ t.Fatalf("got %+v, want %+v", ev, want)
80+ }
81+}
@@ -68,3 +68,14 @@ func TestForwarderStopsOnCancel(t *testing.T) {
68 t.Fatal("run did not stop while blocked on send")68 t.Fatal("run did not stop while blocked on send")
69 }69 }
70 }70 }
71+
72+// Coalesced motions keep the sum of their relative deltas.
73+func TestForwarderSumsDeltas(t *testing.T) {
74+ f := newForwarder()
75+ f.push(ports.PointerMotion{X: 1, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4})
76+ f.push(ports.PointerMotion{X: 2, DX: 10, DY: 20, UnaccelDX: 30, UnaccelDY: 40})
77+ ev, _ := f.pop()
78+ if want := (ports.PointerMotion{X: 2, DX: 11, DY: 22, UnaccelDX: 33, UnaccelDY: 44}); ev != want {
79+ t.Fatalf("got %+v, want %+v", ev, want)
80+ }
81+}
M internal/adapters/libinput/libinput.go +45 -10
@@ -53,6 +53,9 @@ var (
5353 pointerEvent func(ev uintptr) uintptr
5454 pointerDX func(pev uintptr) float64
5555 pointerDY func(pev uintptr) float64
56+ pointerRawDX func(pev uintptr) float64
57+ pointerRawDY func(pev uintptr) float64
58+ pointerUsec func(pev uintptr) uint64
5659 pointerAbsX func(pev uintptr, width uint32) float64
5760 pointerAbsY func(pev uintptr, height uint32) float64
5861 pointerButton func(pev uintptr) uint32
@@ -99,6 +102,9 @@ func load() error {
99102 reg(&pointerEvent, "event_get_pointer_event")
100103 reg(&pointerDX, "event_pointer_get_dx")
101104 reg(&pointerDY, "event_pointer_get_dy")
105+ reg(&pointerRawDX, "event_pointer_get_dx_unaccelerated")
106+ reg(&pointerRawDY, "event_pointer_get_dy_unaccelerated")
107+ reg(&pointerUsec, "event_pointer_get_time_usec")
102108 reg(&pointerAbsX, "event_pointer_get_absolute_x_transformed")
103109 reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")
104110 reg(&pointerButton, "event_pointer_get_button")
@@ -136,7 +142,10 @@ type Options struct {
136142 // Layout is the initial output layout; Layouts replaces it live.
137143 Layout ports.Layout
138144 Layouts <-chan ports.Layout
139- Active <-chan bool
145+ // Constraints holds the pointer lock or confinement of the focused
146+ // window, in global logical coordinates.
147+ Constraints <-chan ports.PointerConstraint
148+ Active <-chan bool
140149 // MoveCursor, when set, places the hardware cursor as soon as motion is
141150 // read, before core sees the event: the output under the pointer and
142151 // the physical position on it.
@@ -202,6 +211,9 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
202211 case l := <-opts.Layouts:
203212 p.setLayout(l)
204213 p.moved(opts.MoveCursor)
214+ case c := <-opts.Constraints:
215+ p.constrain(c)
216+ p.moved(opts.MoveCursor)
205217 default:
206218 }
207219 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
@@ -253,14 +265,19 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
253265 return ke, nil
254266 case evPointerMotion:
255267 pe := pointerEvent(ev)
256- x, y := p.move(pointerDX(pe), pointerDY(pe))
268+ m := p.move(pointerDX(pe), pointerDY(pe))
269+ m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe)
270+ m.TimeMsec, m.TimeUsec = now, pointerUsec(pe)
257271 p.moved(opts.MoveCursor)
258- log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")
259- return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
272+ log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer")
273+ return m, nil
260274 case evPointerAbs:
261275 // Absolute devices (tablets, VMs) map to the whole layout.
262276 pe := pointerEvent(ev)
263277 b := p.bounds()
278+ if p.constraint.Mode == ports.ConstraintLock {
279+ return ports.PointerMotion{X: p.x, Y: p.y, TimeMsec: now}, nil
280+ }
264281 x, y := p.set(float64(b.X)+pointerAbsX(pe, uint32(b.W)), float64(b.Y)+pointerAbsY(pe, uint32(b.H)))
265282 p.moved(opts.MoveCursor)
266283 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
@@ -310,9 +327,10 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
310327 // is divided by the scale of the output under the pointer, so it moves the
311328 // same number of physical pixels on every output.
312329 type pointer struct {
313- x, y float64
314- layout ports.Layout
315- touched bool // false until the user moves it: it follows the primary output
330+ x, y float64
331+ layout ports.Layout
332+ touched bool // false until the user moves it: it follows the primary output
333+ constraint ports.PointerConstraint
316334 }
317335
318336 // newPointer starts at the centre of the primary output, else the first.
@@ -347,17 +365,34 @@ func (p *pointer) scale() float64 {
347365 return 1
348366 }
349367
350-func (p *pointer) move(dx, dy float64) (float64, float64) {
368+// move applies a relative motion and returns it with the logical deltas.
369+// A locked pointer stays still; a confined one stays in its rectangle.
370+func (p *pointer) move(dx, dy float64) ports.PointerMotion {
351371 s := p.scale()
352- return p.set(p.x+dx/s, p.y+dy/s)
372+ dx, dy = dx/s, dy/s
373+ if p.constraint.Mode != ports.ConstraintLock {
374+ p.set(p.x+dx, p.y+dy)
375+ }
376+ return ports.PointerMotion{X: p.x, Y: p.y, DX: dx, DY: dy}
353377 }
354378
355379 func (p *pointer) set(x, y float64) (float64, float64) {
356380 p.touched = true
357- p.x, p.y = p.layout.Clamp(p.x, p.y, x, y)
381+ p.x, p.y = p.constraint.Clamp(p.layout.Clamp(p.x, p.y, x, y))
358382 return p.x, p.y
359383 }
360384
385+// constrain applies a new constraint. Entering or leaving a lock resyncs
386+// the pointer to core's cursor, which stood still meanwhile; a confined
387+// pointer moves inside.
388+func (p *pointer) constrain(c ports.PointerConstraint) {
389+ if c.Mode == ports.ConstraintLock || p.constraint.Mode == ports.ConstraintLock {
390+ p.x, p.y = p.layout.Clamp(c.X, c.Y, c.X, c.Y)
391+ }
392+ p.constraint = c
393+ p.x, p.y = c.Clamp(p.x, p.y)
394+}
395+
361396 // bounds is the rectangle holding every output.
362397 func (p *pointer) bounds() ports.Rect {
363398 var r ports.Rect
@@ -53,6 +53,9 @@ var (
53 pointerEvent func(ev uintptr) uintptr53 pointerEvent func(ev uintptr) uintptr
54 pointerDX func(pev uintptr) float6454 pointerDX func(pev uintptr) float64
55 pointerDY func(pev uintptr) float6455 pointerDY func(pev uintptr) float64
56+ pointerRawDX func(pev uintptr) float64
57+ pointerRawDY func(pev uintptr) float64
58+ pointerUsec func(pev uintptr) uint64
56 pointerAbsX func(pev uintptr, width uint32) float6459 pointerAbsX func(pev uintptr, width uint32) float64
57 pointerAbsY func(pev uintptr, height uint32) float6460 pointerAbsY func(pev uintptr, height uint32) float64
58 pointerButton func(pev uintptr) uint3261 pointerButton func(pev uintptr) uint32
@@ -99,6 +102,9 @@ func load() error {
99 reg(&pointerEvent, "event_get_pointer_event")102 reg(&pointerEvent, "event_get_pointer_event")
100 reg(&pointerDX, "event_pointer_get_dx")103 reg(&pointerDX, "event_pointer_get_dx")
101 reg(&pointerDY, "event_pointer_get_dy")104 reg(&pointerDY, "event_pointer_get_dy")
105+ reg(&pointerRawDX, "event_pointer_get_dx_unaccelerated")
106+ reg(&pointerRawDY, "event_pointer_get_dy_unaccelerated")
107+ reg(&pointerUsec, "event_pointer_get_time_usec")
102 reg(&pointerAbsX, "event_pointer_get_absolute_x_transformed")108 reg(&pointerAbsX, "event_pointer_get_absolute_x_transformed")
103 reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")109 reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")
104 reg(&pointerButton, "event_pointer_get_button")110 reg(&pointerButton, "event_pointer_get_button")
@@ -136,7 +142,10 @@ type Options struct {
136 // Layout is the initial output layout; Layouts replaces it live.142 // Layout is the initial output layout; Layouts replaces it live.
137 Layout ports.Layout143 Layout ports.Layout
138 Layouts <-chan ports.Layout144 Layouts <-chan ports.Layout
139- Active <-chan bool145+ // Constraints holds the pointer lock or confinement of the focused
146+ // window, in global logical coordinates.
147+ Constraints <-chan ports.PointerConstraint
148+ Active <-chan bool
140 // MoveCursor, when set, places the hardware cursor as soon as motion is149 // MoveCursor, when set, places the hardware cursor as soon as motion is
141 // read, before core sees the event: the output under the pointer and150 // read, before core sees the event: the output under the pointer and
142 // the physical position on it.151 // the physical position on it.
@@ -202,6 +211,9 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
202 case l := <-opts.Layouts:211 case l := <-opts.Layouts:
203 p.setLayout(l)212 p.setLayout(l)
204 p.moved(opts.MoveCursor)213 p.moved(opts.MoveCursor)
214+ case c := <-opts.Constraints:
215+ p.constrain(c)
216+ p.moved(opts.MoveCursor)
205 default:217 default:
206 }218 }
207 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}219 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
@@ -253,14 +265,19 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
253 return ke, nil265 return ke, nil
254 case evPointerMotion:266 case evPointerMotion:
255 pe := pointerEvent(ev)267 pe := pointerEvent(ev)
256- x, y := p.move(pointerDX(pe), pointerDY(pe))268+ m := p.move(pointerDX(pe), pointerDY(pe))
269+ m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe)
270+ m.TimeMsec, m.TimeUsec = now, pointerUsec(pe)
257 p.moved(opts.MoveCursor)271 p.moved(opts.MoveCursor)
258- log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")272+ log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer")
259- return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil273+ return m, nil
260 case evPointerAbs:274 case evPointerAbs:
261 // Absolute devices (tablets, VMs) map to the whole layout.275 // Absolute devices (tablets, VMs) map to the whole layout.
262 pe := pointerEvent(ev)276 pe := pointerEvent(ev)
263 b := p.bounds()277 b := p.bounds()
278+ if p.constraint.Mode == ports.ConstraintLock {
279+ return ports.PointerMotion{X: p.x, Y: p.y, TimeMsec: now}, nil
280+ }
264 x, y := p.set(float64(b.X)+pointerAbsX(pe, uint32(b.W)), float64(b.Y)+pointerAbsY(pe, uint32(b.H)))281 x, y := p.set(float64(b.X)+pointerAbsX(pe, uint32(b.W)), float64(b.Y)+pointerAbsY(pe, uint32(b.H)))
265 p.moved(opts.MoveCursor)282 p.moved(opts.MoveCursor)
266 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil283 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
@@ -310,9 +327,10 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
310 // is divided by the scale of the output under the pointer, so it moves the327 // is divided by the scale of the output under the pointer, so it moves the
311 // same number of physical pixels on every output.328 // same number of physical pixels on every output.
312 type pointer struct {329 type pointer struct {
313- x, y float64330+ x, y float64
314- layout ports.Layout331+ layout ports.Layout
315- touched bool // false until the user moves it: it follows the primary output332+ touched bool // false until the user moves it: it follows the primary output
333+ constraint ports.PointerConstraint
316 }334 }
317 335
318 // newPointer starts at the centre of the primary output, else the first.336 // newPointer starts at the centre of the primary output, else the first.
@@ -347,17 +365,34 @@ func (p *pointer) scale() float64 {
347 return 1365 return 1
348 }366 }
349 367
350-func (p *pointer) move(dx, dy float64) (float64, float64) {368+// move applies a relative motion and returns it with the logical deltas.
369+// A locked pointer stays still; a confined one stays in its rectangle.
370+func (p *pointer) move(dx, dy float64) ports.PointerMotion {
351 s := p.scale()371 s := p.scale()
352- return p.set(p.x+dx/s, p.y+dy/s)372+ dx, dy = dx/s, dy/s
373+ if p.constraint.Mode != ports.ConstraintLock {
374+ p.set(p.x+dx, p.y+dy)
375+ }
376+ return ports.PointerMotion{X: p.x, Y: p.y, DX: dx, DY: dy}
353 }377 }
354 378
355 func (p *pointer) set(x, y float64) (float64, float64) {379 func (p *pointer) set(x, y float64) (float64, float64) {
356 p.touched = true380 p.touched = true
357- p.x, p.y = p.layout.Clamp(p.x, p.y, x, y)381+ p.x, p.y = p.constraint.Clamp(p.layout.Clamp(p.x, p.y, x, y))
358 return p.x, p.y382 return p.x, p.y
359 }383 }
360 384
385+// constrain applies a new constraint. Entering or leaving a lock resyncs
386+// the pointer to core's cursor, which stood still meanwhile; a confined
387+// pointer moves inside.
388+func (p *pointer) constrain(c ports.PointerConstraint) {
389+ if c.Mode == ports.ConstraintLock || p.constraint.Mode == ports.ConstraintLock {
390+ p.x, p.y = p.layout.Clamp(c.X, c.Y, c.X, c.Y)
391+ }
392+ p.constraint = c
393+ p.x, p.y = c.Clamp(p.x, p.y)
394+}
395+
361 // bounds is the rectangle holding every output.396 // bounds is the rectangle holding every output.
362 func (p *pointer) bounds() ports.Rect {397 func (p *pointer) bounds() ports.Rect {
363 var r ports.Rect398 var r ports.Rect
M internal/adapters/libinput/libinput_test.go +34 -4
@@ -39,13 +39,13 @@ func TestPointerAcrossOutputs(t *testing.T) {
3939 t.Fatal(p.x, p.y)
4040 }
4141 // Off the bottom of A: clamped to A.
42- if x, y := p.move(-80, 100); x != 0 || y != 49 {
43- t.Fatalf("got %v %v", x, y)
42+ if m := p.move(-80, 100); m.X != 0 || m.Y != 49 {
43+ t.Fatalf("got %v %v", m.X, m.Y)
4444 }
4545 // Into B, where deltas count in physical pixels (scale 2).
4646 p.set(150, 20)
47- if x, y := p.move(20, 10); x != 160 || y != 25 {
48- t.Fatalf("got %v %v", x, y)
47+ if m := p.move(20, 10); m.X != 160 || m.Y != 25 || m.DX != 10 || m.DY != 5 {
48+ t.Fatalf("got %+v", m)
4949 }
5050 var out string
5151 var px, py float64
@@ -69,3 +69,33 @@ func TestPointerStartsOnPrimary(t *testing.T) {
6969 t.Fatal(p.x, p.y)
7070 }
7171 }
72+
73+func TestPointerConstraints(t *testing.T) {
74+ p := newPointer(ports.Layout{{Info: ports.OutputInfo{Name: "A"}, Width: 100, Height: 100, Scale: 1}})
75+ p.constrain(ports.PointerConstraint{Mode: ports.ConstraintLock, X: 50, Y: 50})
76+ if m := p.move(10, -5); m.X != 50 || m.Y != 50 || m.DX != 10 || m.DY != -5 {
77+ t.Fatalf("locked: %+v", m)
78+ }
79+ // Confining moves the pointer inside, then keeps it there.
80+ p.constrain(ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 60, Y: 10, W: 20, H: 20}, X: 50, Y: 50})
81+ if p.x != 60 || p.y != 29 {
82+ t.Fatal(p.x, p.y)
83+ }
84+ if m := p.move(100, 0); m.X != 79 || m.DX != 100 {
85+ t.Fatalf("confined: %+v", m)
86+ }
87+ p.constrain(ports.PointerConstraint{})
88+ if m := p.move(10, 0); m.X != 89 {
89+ t.Fatalf("free: %+v", m)
90+ }
91+ // A lock takes core's cursor position, and so does its release.
92+ p.constrain(ports.PointerConstraint{Mode: ports.ConstraintLock, X: 20, Y: 30})
93+ if p.x != 20 || p.y != 30 {
94+ t.Fatal(p.x, p.y)
95+ }
96+ p.move(40, 40)
97+ p.constrain(ports.PointerConstraint{X: 21, Y: 31})
98+ if p.x != 21 || p.y != 31 {
99+ t.Fatal(p.x, p.y)
100+ }
101+}
@@ -39,13 +39,13 @@ func TestPointerAcrossOutputs(t *testing.T) {
39 t.Fatal(p.x, p.y)39 t.Fatal(p.x, p.y)
40 }40 }
41 // Off the bottom of A: clamped to A.41 // Off the bottom of A: clamped to A.
42- if x, y := p.move(-80, 100); x != 0 || y != 49 {42+ if m := p.move(-80, 100); m.X != 0 || m.Y != 49 {
43- t.Fatalf("got %v %v", x, y)43+ t.Fatalf("got %v %v", m.X, m.Y)
44 }44 }
45 // Into B, where deltas count in physical pixels (scale 2).45 // Into B, where deltas count in physical pixels (scale 2).
46 p.set(150, 20)46 p.set(150, 20)
47- if x, y := p.move(20, 10); x != 160 || y != 25 {47+ if m := p.move(20, 10); m.X != 160 || m.Y != 25 || m.DX != 10 || m.DY != 5 {
48- t.Fatalf("got %v %v", x, y)48+ t.Fatalf("got %+v", m)
49 }49 }
50 var out string50 var out string
51 var px, py float6451 var px, py float64
@@ -69,3 +69,33 @@ func TestPointerStartsOnPrimary(t *testing.T) {
69 t.Fatal(p.x, p.y)69 t.Fatal(p.x, p.y)
70 }70 }
71 }71 }
72+
73+func TestPointerConstraints(t *testing.T) {
74+ p := newPointer(ports.Layout{{Info: ports.OutputInfo{Name: "A"}, Width: 100, Height: 100, Scale: 1}})
75+ p.constrain(ports.PointerConstraint{Mode: ports.ConstraintLock, X: 50, Y: 50})
76+ if m := p.move(10, -5); m.X != 50 || m.Y != 50 || m.DX != 10 || m.DY != -5 {
77+ t.Fatalf("locked: %+v", m)
78+ }
79+ // Confining moves the pointer inside, then keeps it there.
80+ p.constrain(ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 60, Y: 10, W: 20, H: 20}, X: 50, Y: 50})
81+ if p.x != 60 || p.y != 29 {
82+ t.Fatal(p.x, p.y)
83+ }
84+ if m := p.move(100, 0); m.X != 79 || m.DX != 100 {
85+ t.Fatalf("confined: %+v", m)
86+ }
87+ p.constrain(ports.PointerConstraint{})
88+ if m := p.move(10, 0); m.X != 89 {
89+ t.Fatalf("free: %+v", m)
90+ }
91+ // A lock takes core's cursor position, and so does its release.
92+ p.constrain(ports.PointerConstraint{Mode: ports.ConstraintLock, X: 20, Y: 30})
93+ if p.x != 20 || p.y != 30 {
94+ t.Fatal(p.x, p.y)
95+ }
96+ p.move(40, 40)
97+ p.constrain(ports.PointerConstraint{X: 21, Y: 31})
98+ if p.x != 21 || p.y != 31 {
99+ t.Fatal(p.x, p.y)
100+ }
101+}
A internal/adapters/wayland/constraints.go +278 -0
@@ -0,0 +1,278 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
6+ "github.com/bnema/purego-libwayland/protocol/relativepointer"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+func registerPointerConstraints(d *server.Display, s *Server) error {
12+ if err := relativepointer.NewZwpRelativePointerManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
13+ _, _ = relativepointer.NewZwpRelativePointerManagerV1(c, int32(v), id, relativeManager{s})
14+ }); err != nil {
15+ return err
16+ }
17+ return pointerconstraints.NewZwpPointerConstraintsV1Global(d, 1, func(c server.Client, v, id uint32) {
18+ _, _ = pointerconstraints.NewZwpPointerConstraintsV1(c, int32(v), id, constraintManager{s})
19+ })
20+}
21+
22+type relativeManager struct{ server *Server }
23+
24+func (relativeManager) Destroy(*relativepointer.ZwpRelativePointerManagerV1) {}
25+func (m relativeManager) GetRelativePointer(r *relativepointer.ZwpRelativePointerManagerV1, id uint32, _ *wayland.Pointer) {
26+ rel, err := relativepointer.NewZwpRelativePointerV1(r.Client(), r.Version(), id, relativeHandler{})
27+ if err != nil {
28+ return
29+ }
30+ s, c := m.server, r.Client()
31+ s.relatives[c] = append(s.relatives[c], rel)
32+ rel.OnDestroy = func() {
33+ if list := removeItem(s.relatives[c], rel); len(list) > 0 {
34+ s.relatives[c] = list
35+ } else {
36+ delete(s.relatives, c)
37+ }
38+ }
39+}
40+
41+type relativeHandler struct{}
42+
43+func (relativeHandler) Destroy(*relativepointer.ZwpRelativePointerV1) {}
44+
45+// relativeMotion sends the motion deltas to the window client's relative
46+// pointers. Absolute devices have no deltas and send nothing.
47+// It reports whether any event was sent.
48+func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
49+ if c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
50+ return false
51+ }
52+ if !w.mapped || !w.xdg.resource.Resource.Alive() {
53+ return false
54+ }
55+ sent := false
56+ for _, rel := range s.relatives[w.xdg.resource.Client()] {
57+ if rel.Resource.Alive() {
58+ rel.SendRelativeMotion(uint32(c.TimeUsec>>32), uint32(c.TimeUsec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))
59+ sent = true
60+ }
61+ }
62+ return sent
63+}
64+
65+// constraint is a zwp_locked_pointer_v1 or zwp_confined_pointer_v1 on a
66+// surface. Its region is surface-local; empty means the whole surface.
67+type constraint struct {
68+ surface *surface
69+ lock *pointerconstraints.ZwpLockedPointerV1
70+ confine *pointerconstraints.ZwpConfinedPointerV1
71+ region ports.Rect
72+ pending *ports.Rect // set_region waiting for the surface commit
73+ persistent bool
74+ active bool
75+ defunct bool // a oneshot constraint that was deactivated
76+}
77+
78+type constraintManager struct{ server *Server }
79+
80+func (constraintManager) Destroy(*pointerconstraints.ZwpPointerConstraintsV1) {}
81+func (m constraintManager) LockPointer(r *pointerconstraints.ZwpPointerConstraintsV1, id uint32, surf *wayland.Surface, _ *wayland.Pointer, reg *wayland.Region, lifetime uint32) {
82+ c := m.create(r, surf, reg, lifetime)
83+ if c == nil {
84+ // An unknown surface: an inert object the client can destroy.
85+ _, _ = pointerconstraints.NewZwpLockedPointerV1(r.Client(), r.Version(), id, lockHandler{&constraint{}})
86+ return
87+ }
88+ lock, err := pointerconstraints.NewZwpLockedPointerV1(r.Client(), r.Version(), id, lockHandler{c})
89+ if err != nil {
90+ delete(m.server.constraints, c.surface)
91+ return
92+ }
93+ c.lock = lock
94+ lock.OnDestroy = func() { m.server.dropConstraint(c) }
95+ m.server.updateConstraint()
96+}
97+func (m constraintManager) ConfinePointer(r *pointerconstraints.ZwpPointerConstraintsV1, id uint32, surf *wayland.Surface, _ *wayland.Pointer, reg *wayland.Region, lifetime uint32) {
98+ c := m.create(r, surf, reg, lifetime)
99+ if c == nil {
100+ _, _ = pointerconstraints.NewZwpConfinedPointerV1(r.Client(), r.Version(), id, confineHandler{&constraint{}})
101+ return
102+ }
103+ confine, err := pointerconstraints.NewZwpConfinedPointerV1(r.Client(), r.Version(), id, confineHandler{c})
104+ if err != nil {
105+ delete(m.server.constraints, c.surface)
106+ return
107+ }
108+ c.confine = confine
109+ confine.OnDestroy = func() { m.server.dropConstraint(c) }
110+ m.server.updateConstraint()
111+}
112+
113+// create validates the request and registers the constraint on the surface.
114+func (m constraintManager) create(r *pointerconstraints.ZwpPointerConstraintsV1, surf *wayland.Surface, reg *wayland.Region, lifetime uint32) *constraint {
115+ s := m.server
116+ if surf == nil {
117+ return nil
118+ }
119+ state := s.surfaces[surf.Resource]
120+ if state == nil {
121+ return nil
122+ }
123+ if s.constraints[state] != nil {
124+ r.PostError(uint32(pointerconstraints.ZwpPointerConstraintsV1ErrorAlreadyConstrained), "surface already constrained")
125+ return nil
126+ }
127+ if state.kind == roleCursor {
128+ return nil
129+ }
130+ c := &constraint{surface: state, region: s.regionBox(reg), persistent: lifetime == uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent)}
131+ s.constraints[state] = c
132+ return c
133+}
134+
135+// regionBox copies a region: the protocol reads it at request time.
136+func (s *Server) regionBox(reg *wayland.Region) ports.Rect {
137+ if reg == nil || reg.Resource == nil {
138+ return ports.Rect{}
139+ }
140+ if g := s.regions[reg.Resource]; g != nil {
141+ return g.box
142+ }
143+ return ports.Rect{}
144+}
145+
146+type lockHandler struct{ c *constraint }
147+
148+func (lockHandler) Destroy(*pointerconstraints.ZwpLockedPointerV1) {}
149+
150+// SetCursorPositionHint is ignored: the pointer stays where it locked.
151+func (lockHandler) SetCursorPositionHint(*pointerconstraints.ZwpLockedPointerV1, server.Fixed, server.Fixed) {
152+}
153+func (h lockHandler) SetRegion(_ *pointerconstraints.ZwpLockedPointerV1, reg *wayland.Region) {
154+ if h.c.surface == nil {
155+ return
156+ }
157+ r := h.c.surface.server.regionBox(reg)
158+ h.c.pending = &r
159+}
160+
161+type confineHandler struct{ c *constraint }
162+
163+func (confineHandler) Destroy(*pointerconstraints.ZwpConfinedPointerV1) {}
164+func (h confineHandler) SetRegion(_ *pointerconstraints.ZwpConfinedPointerV1, reg *wayland.Region) {
165+ if h.c.surface == nil {
166+ return
167+ }
168+ r := h.c.surface.server.regionBox(reg)
169+ h.c.pending = &r
170+}
171+
172+// commitConstraint applies a pending region on the surface commit, after
173+// the new window geometry: core receives the region window-local.
174+func (s *surface) commitConstraint(geometry bool) {
175+ c := s.server.constraints[s]
176+ if c == nil || (c.pending == nil && !geometry) {
177+ return
178+ }
179+ if c.pending != nil {
180+ c.region, c.pending = *c.pending, nil
181+ }
182+ if c.active {
183+ s.server.emitConstraint(c)
184+ } else {
185+ s.server.updateConstraint()
186+ }
187+}
188+
189+// dropConstraint forgets a destroyed constraint or one on a destroyed surface.
190+func (s *Server) dropConstraint(c *constraint) {
191+ if s.constraints[c.surface] == c {
192+ delete(s.constraints, c.surface)
193+ }
194+ if s.constraint == c {
195+ c.active = false
196+ s.constraint = nil
197+ if c.lock != nil && c.lock.Resource.Alive() {
198+ c.lock.SendUnlocked()
199+ }
200+ if c.confine != nil && c.confine.Resource.Alive() {
201+ c.confine.SendUnconfined()
202+ }
203+ s.emit(ports.PointerConstrained{})
204+ }
205+ s.updateConstraint()
206+}
207+
208+// updateConstraint activates the constraint of the window holding both
209+// pointer and keyboard focus, and deactivates any other.
210+func (s *Server) updateConstraint() {
211+ var want *constraint
212+ if w := s.windows[s.pointerFocus]; w != nil && w.mapped && s.pointerFocus == s.focused {
213+ if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {
214+ want = c
215+ }
216+ }
217+ if want == s.constraint {
218+ return
219+ }
220+ if old := s.constraint; old != nil {
221+ old.active = false
222+ if !old.persistent {
223+ old.defunct = true
224+ }
225+ if old.lock != nil && old.lock.Resource.Alive() {
226+ old.lock.SendUnlocked()
227+ }
228+ if old.confine != nil && old.confine.Resource.Alive() {
229+ old.confine.SendUnconfined()
230+ }
231+ }
232+ s.constraint = want
233+ if want == nil {
234+ s.emit(ports.PointerConstrained{})
235+ return
236+ }
237+ want.active = true
238+ if want.lock != nil {
239+ want.lock.SendLocked()
240+ } else {
241+ want.confine.SendConfined()
242+ }
243+ s.emitConstraint(want)
244+}
245+
246+// emitConstraint tells core about the active constraint, window-local.
247+func (s *Server) emitConstraint(c *constraint) {
248+ x := c.surface.xdg
249+ if x == nil || x.window == nil {
250+ return
251+ }
252+ mode := ports.ConstraintConfine
253+ if c.lock != nil {
254+ mode = ports.ConstraintLock
255+ }
256+ r := c.region
257+ if r.W > 0 && r.H > 0 {
258+ r.X -= x.geometry.X
259+ r.Y -= x.geometry.Y
260+ }
261+ s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
262+}
263+
264+// contains reports whether the pointer is in the region: a constraint
265+// activates only there, so it never warps the pointer.
266+func (c *constraint) contains(w *window) bool {
267+ r := c.region
268+ if r.W <= 0 || r.H <= 0 {
269+ return true
270+ }
271+ x, y := w.surfacePoint(w.xdg.server.pointerX, w.xdg.server.pointerY)
272+ return x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H)
273+}
274+
275+// locked reports whether the window's pointer is locked.
276+func (s *Server) locked(w *window) bool {
277+ return s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
278+}
@@ -0,0 +1,278 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
6+ "github.com/bnema/purego-libwayland/protocol/relativepointer"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+func registerPointerConstraints(d *server.Display, s *Server) error {
12+ if err := relativepointer.NewZwpRelativePointerManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
13+ _, _ = relativepointer.NewZwpRelativePointerManagerV1(c, int32(v), id, relativeManager{s})
14+ }); err != nil {
15+ return err
16+ }
17+ return pointerconstraints.NewZwpPointerConstraintsV1Global(d, 1, func(c server.Client, v, id uint32) {
18+ _, _ = pointerconstraints.NewZwpPointerConstraintsV1(c, int32(v), id, constraintManager{s})
19+ })
20+}
21+
22+type relativeManager struct{ server *Server }
23+
24+func (relativeManager) Destroy(*relativepointer.ZwpRelativePointerManagerV1) {}
25+func (m relativeManager) GetRelativePointer(r *relativepointer.ZwpRelativePointerManagerV1, id uint32, _ *wayland.Pointer) {
26+ rel, err := relativepointer.NewZwpRelativePointerV1(r.Client(), r.Version(), id, relativeHandler{})
27+ if err != nil {
28+ return
29+ }
30+ s, c := m.server, r.Client()
31+ s.relatives[c] = append(s.relatives[c], rel)
32+ rel.OnDestroy = func() {
33+ if list := removeItem(s.relatives[c], rel); len(list) > 0 {
34+ s.relatives[c] = list
35+ } else {
36+ delete(s.relatives, c)
37+ }
38+ }
39+}
40+
41+type relativeHandler struct{}
42+
43+func (relativeHandler) Destroy(*relativepointer.ZwpRelativePointerV1) {}
44+
45+// relativeMotion sends the motion deltas to the window client's relative
46+// pointers. Absolute devices have no deltas and send nothing.
47+// It reports whether any event was sent.
48+func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
49+ if c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
50+ return false
51+ }
52+ if !w.mapped || !w.xdg.resource.Resource.Alive() {
53+ return false
54+ }
55+ sent := false
56+ for _, rel := range s.relatives[w.xdg.resource.Client()] {
57+ if rel.Resource.Alive() {
58+ rel.SendRelativeMotion(uint32(c.TimeUsec>>32), uint32(c.TimeUsec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))
59+ sent = true
60+ }
61+ }
62+ return sent
63+}
64+
65+// constraint is a zwp_locked_pointer_v1 or zwp_confined_pointer_v1 on a
66+// surface. Its region is surface-local; empty means the whole surface.
67+type constraint struct {
68+ surface *surface
69+ lock *pointerconstraints.ZwpLockedPointerV1
70+ confine *pointerconstraints.ZwpConfinedPointerV1
71+ region ports.Rect
72+ pending *ports.Rect // set_region waiting for the surface commit
73+ persistent bool
74+ active bool
75+ defunct bool // a oneshot constraint that was deactivated
76+}
77+
78+type constraintManager struct{ server *Server }
79+
80+func (constraintManager) Destroy(*pointerconstraints.ZwpPointerConstraintsV1) {}
81+func (m constraintManager) LockPointer(r *pointerconstraints.ZwpPointerConstraintsV1, id uint32, surf *wayland.Surface, _ *wayland.Pointer, reg *wayland.Region, lifetime uint32) {
82+ c := m.create(r, surf, reg, lifetime)
83+ if c == nil {
84+ // An unknown surface: an inert object the client can destroy.
85+ _, _ = pointerconstraints.NewZwpLockedPointerV1(r.Client(), r.Version(), id, lockHandler{&constraint{}})
86+ return
87+ }
88+ lock, err := pointerconstraints.NewZwpLockedPointerV1(r.Client(), r.Version(), id, lockHandler{c})
89+ if err != nil {
90+ delete(m.server.constraints, c.surface)
91+ return
92+ }
93+ c.lock = lock
94+ lock.OnDestroy = func() { m.server.dropConstraint(c) }
95+ m.server.updateConstraint()
96+}
97+func (m constraintManager) ConfinePointer(r *pointerconstraints.ZwpPointerConstraintsV1, id uint32, surf *wayland.Surface, _ *wayland.Pointer, reg *wayland.Region, lifetime uint32) {
98+ c := m.create(r, surf, reg, lifetime)
99+ if c == nil {
100+ _, _ = pointerconstraints.NewZwpConfinedPointerV1(r.Client(), r.Version(), id, confineHandler{&constraint{}})
101+ return
102+ }
103+ confine, err := pointerconstraints.NewZwpConfinedPointerV1(r.Client(), r.Version(), id, confineHandler{c})
104+ if err != nil {
105+ delete(m.server.constraints, c.surface)
106+ return
107+ }
108+ c.confine = confine
109+ confine.OnDestroy = func() { m.server.dropConstraint(c) }
110+ m.server.updateConstraint()
111+}
112+
113+// create validates the request and registers the constraint on the surface.
114+func (m constraintManager) create(r *pointerconstraints.ZwpPointerConstraintsV1, surf *wayland.Surface, reg *wayland.Region, lifetime uint32) *constraint {
115+ s := m.server
116+ if surf == nil {
117+ return nil
118+ }
119+ state := s.surfaces[surf.Resource]
120+ if state == nil {
121+ return nil
122+ }
123+ if s.constraints[state] != nil {
124+ r.PostError(uint32(pointerconstraints.ZwpPointerConstraintsV1ErrorAlreadyConstrained), "surface already constrained")
125+ return nil
126+ }
127+ if state.kind == roleCursor {
128+ return nil
129+ }
130+ c := &constraint{surface: state, region: s.regionBox(reg), persistent: lifetime == uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent)}
131+ s.constraints[state] = c
132+ return c
133+}
134+
135+// regionBox copies a region: the protocol reads it at request time.
136+func (s *Server) regionBox(reg *wayland.Region) ports.Rect {
137+ if reg == nil || reg.Resource == nil {
138+ return ports.Rect{}
139+ }
140+ if g := s.regions[reg.Resource]; g != nil {
141+ return g.box
142+ }
143+ return ports.Rect{}
144+}
145+
146+type lockHandler struct{ c *constraint }
147+
148+func (lockHandler) Destroy(*pointerconstraints.ZwpLockedPointerV1) {}
149+
150+// SetCursorPositionHint is ignored: the pointer stays where it locked.
151+func (lockHandler) SetCursorPositionHint(*pointerconstraints.ZwpLockedPointerV1, server.Fixed, server.Fixed) {
152+}
153+func (h lockHandler) SetRegion(_ *pointerconstraints.ZwpLockedPointerV1, reg *wayland.Region) {
154+ if h.c.surface == nil {
155+ return
156+ }
157+ r := h.c.surface.server.regionBox(reg)
158+ h.c.pending = &r
159+}
160+
161+type confineHandler struct{ c *constraint }
162+
163+func (confineHandler) Destroy(*pointerconstraints.ZwpConfinedPointerV1) {}
164+func (h confineHandler) SetRegion(_ *pointerconstraints.ZwpConfinedPointerV1, reg *wayland.Region) {
165+ if h.c.surface == nil {
166+ return
167+ }
168+ r := h.c.surface.server.regionBox(reg)
169+ h.c.pending = &r
170+}
171+
172+// commitConstraint applies a pending region on the surface commit, after
173+// the new window geometry: core receives the region window-local.
174+func (s *surface) commitConstraint(geometry bool) {
175+ c := s.server.constraints[s]
176+ if c == nil || (c.pending == nil && !geometry) {
177+ return
178+ }
179+ if c.pending != nil {
180+ c.region, c.pending = *c.pending, nil
181+ }
182+ if c.active {
183+ s.server.emitConstraint(c)
184+ } else {
185+ s.server.updateConstraint()
186+ }
187+}
188+
189+// dropConstraint forgets a destroyed constraint or one on a destroyed surface.
190+func (s *Server) dropConstraint(c *constraint) {
191+ if s.constraints[c.surface] == c {
192+ delete(s.constraints, c.surface)
193+ }
194+ if s.constraint == c {
195+ c.active = false
196+ s.constraint = nil
197+ if c.lock != nil && c.lock.Resource.Alive() {
198+ c.lock.SendUnlocked()
199+ }
200+ if c.confine != nil && c.confine.Resource.Alive() {
201+ c.confine.SendUnconfined()
202+ }
203+ s.emit(ports.PointerConstrained{})
204+ }
205+ s.updateConstraint()
206+}
207+
208+// updateConstraint activates the constraint of the window holding both
209+// pointer and keyboard focus, and deactivates any other.
210+func (s *Server) updateConstraint() {
211+ var want *constraint
212+ if w := s.windows[s.pointerFocus]; w != nil && w.mapped && s.pointerFocus == s.focused {
213+ if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {
214+ want = c
215+ }
216+ }
217+ if want == s.constraint {
218+ return
219+ }
220+ if old := s.constraint; old != nil {
221+ old.active = false
222+ if !old.persistent {
223+ old.defunct = true
224+ }
225+ if old.lock != nil && old.lock.Resource.Alive() {
226+ old.lock.SendUnlocked()
227+ }
228+ if old.confine != nil && old.confine.Resource.Alive() {
229+ old.confine.SendUnconfined()
230+ }
231+ }
232+ s.constraint = want
233+ if want == nil {
234+ s.emit(ports.PointerConstrained{})
235+ return
236+ }
237+ want.active = true
238+ if want.lock != nil {
239+ want.lock.SendLocked()
240+ } else {
241+ want.confine.SendConfined()
242+ }
243+ s.emitConstraint(want)
244+}
245+
246+// emitConstraint tells core about the active constraint, window-local.
247+func (s *Server) emitConstraint(c *constraint) {
248+ x := c.surface.xdg
249+ if x == nil || x.window == nil {
250+ return
251+ }
252+ mode := ports.ConstraintConfine
253+ if c.lock != nil {
254+ mode = ports.ConstraintLock
255+ }
256+ r := c.region
257+ if r.W > 0 && r.H > 0 {
258+ r.X -= x.geometry.X
259+ r.Y -= x.geometry.Y
260+ }
261+ s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
262+}
263+
264+// contains reports whether the pointer is in the region: a constraint
265+// activates only there, so it never warps the pointer.
266+func (c *constraint) contains(w *window) bool {
267+ r := c.region
268+ if r.W <= 0 || r.H <= 0 {
269+ return true
270+ }
271+ x, y := w.surfacePoint(w.xdg.server.pointerX, w.xdg.server.pointerY)
272+ return x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H)
273+}
274+
275+// locked reports whether the window's pointer is locked.
276+func (s *Server) locked(w *window) bool {
277+ return s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
278+}
A internal/adapters/wayland/constraints_test.go +234 -0
@@ -0,0 +1,234 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
9+ "github.com/bnema/purego-libwayland/protocol/relativepointer"
10+ "github.com/bnema/purego-libwayland/protocol/wayland"
11+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
12+ "github.com/bnema/wlturbo"
13+)
14+
15+// eventLog records the opcodes (and first fixed args) a proxy receives.
16+type eventLog struct {
17+ wlturbo.BaseProxy
18+ events chan []float64 // opcode, then fixed args for relative motion
19+}
20+
21+func (p *eventLog) Dispatch(e *wlturbo.Event) {
22+ ev := []float64{float64(e.Opcode)}
23+ if e.Opcode == uint16(relativepointer.ZwpRelativePointerV1EventRelativeMotion) && e.ProxyID == p.ID() {
24+ e.Uint32()
25+ e.Uint32()
26+ for range 4 {
27+ ev = append(ev, e.Fixed().Float64())
28+ }
29+ }
30+ p.events <- ev
31+}
32+
33+func logProxy(t *testing.T, c *wlturbo.Display, id uint32) chan []float64 {
34+ t.Helper()
35+ p := &eventLog{events: make(chan []float64, 64)}
36+ p.SetID(id)
37+ c.Context().Register(p)
38+ return p.events
39+}
40+
41+// next dispatches until the proxy receives an event.
42+func next(t *testing.T, c *wlturbo.Display, events chan []float64) []float64 {
43+ t.Helper()
44+ deadline := time.Now().Add(2 * time.Second)
45+ for time.Now().Before(deadline) {
46+ select {
47+ case ev := <-events:
48+ return ev
49+ default:
50+ }
51+ if err := c.Roundtrip(); err != nil {
52+ t.Fatal(err)
53+ }
54+ }
55+ t.Fatal("no event")
56+ return nil
57+}
58+
59+func constrained(t *testing.T, events <-chan ports.ClientEvent) ports.PointerConstrained {
60+ t.Helper()
61+ for {
62+ if v, ok := waitEvent(t, events, 2*time.Second).(ports.PointerConstrained); ok {
63+ return v
64+ }
65+ }
66+}
67+
68+func TestPointerLockAndRelativeMotion(t *testing.T) {
69+ s, events, commands, dir := lifecycleServer(t)
70+ c := protocolClient(t, s, dir)
71+ seat := bindProtocol(t, c, "wl_seat")
72+ registerProtocol(t, c, seat)
73+ pointer := c.AllocateID()
74+ pointerEvents := logProxy(t, c, pointer)
75+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
76+ relManager := bindProtocol(t, c, "zwp_relative_pointer_manager_v1")
77+ rel := c.AllocateID()
78+ relEvents := logProxy(t, c, rel)
79+ requestProtocol(t, c, relManager, relativepointer.ZwpRelativePointerManagerV1RequestGetRelativePointer, rel, pointer)
80+ w, surf, _ := surfaceMapper(t, c, events)()
81+
82+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
83+ lock := c.AllocateID()
84+ lockEvents := logProxy(t, c, lock)
85+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
86+ if err := c.Roundtrip(); err != nil {
87+ t.Fatal(err)
88+ }
89+ select {
90+ case ev := <-lockEvents:
91+ t.Fatalf("locked without focus: %v", ev)
92+ default:
93+ }
94+
95+ // Pointer and keyboard focus activate the lock.
96+ commands <- ports.PointerFocus{ID: w.ID}
97+ commands <- ports.FocusWindow{ID: w.ID}
98+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
99+ t.Fatalf("lock event %v", ev)
100+ }
101+ if got := constrained(t, events); got.ID != w.ID || got.Mode != ports.ConstraintLock {
102+ t.Fatalf("constrained %+v", got)
103+ }
104+ for len(pointerEvents) > 0 {
105+ <-pointerEvents
106+ }
107+
108+ // Locked: relative motion flows, absolute motion does not.
109+ commands <- ports.PointerMotionTo{ID: w.ID, X: 5, Y: 5, DX: 1.5, DY: -2, UnaccelDX: 3, UnaccelDY: -4}
110+ if ev := next(t, c, relEvents); len(ev) != 5 || ev[1] != 1.5 || ev[2] != -2 || ev[3] != 3 || ev[4] != -4 {
111+ t.Fatalf("relative motion %v", ev)
112+ }
113+ for len(pointerEvents) > 0 {
114+ if ev := <-pointerEvents; ev[0] == float64(wayland.PointerEventMotion) {
115+ t.Fatal("motion sent to a locked pointer")
116+ }
117+ }
118+
119+ // Losing focus unlocks; a persistent lock comes back.
120+ commands <- ports.FocusWindow{}
121+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventUnlocked) {
122+ t.Fatalf("unlock event %v", ev)
123+ }
124+ if got := constrained(t, events); got.ID != 0 {
125+ t.Fatalf("constrained %+v", got)
126+ }
127+ commands <- ports.FocusWindow{ID: w.ID}
128+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
129+ t.Fatalf("relock event %v", ev)
130+ }
131+}
132+
133+func TestPointerConfineOneshotRegion(t *testing.T) {
134+ s, events, commands, dir := lifecycleServer(t)
135+ c := protocolClient(t, s, dir)
136+ seat := bindProtocol(t, c, "wl_seat")
137+ registerProtocol(t, c, seat)
138+ pointer := c.AllocateID()
139+ pointerEvents := logProxy(t, c, pointer)
140+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
141+ comp := bindProtocol(t, c, "wl_compositor")
142+ w, surf, xdgID := surfaceMapper(t, c, events)()
143+ region := c.AllocateID()
144+ registerProtocol(t, c, region)
145+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
146+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(2), int32(3), int32(10), int32(10))
147+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(20), int32(3), int32(5), int32(20))
148+
149+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
150+ confine := c.AllocateID()
151+ confineEvents := logProxy(t, c, confine)
152+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestConfinePointer, confine, surf, pointer, region, uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot))
153+ // The region is copied: changing it later has no effect.
154+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(0), int32(0), int32(100), int32(100))
155+ // Outside the region the constraint waits for the pointer.
156+ commands <- ports.FocusWindow{ID: w.ID}
157+ commands <- ports.PointerFocus{ID: w.ID}
158+ commands <- ports.PointerMotionTo{ID: w.ID, X: 50, Y: 50}
159+ // Sync on the motion reaching the client before checking.
160+ for ev := next(t, c, pointerEvents); ev[0] != float64(wayland.PointerEventMotion); ev = next(t, c, pointerEvents) {
161+ }
162+ if len(confineEvents) > 0 {
163+ t.Fatal("confined outside the region")
164+ }
165+ commands <- ports.PointerMotionTo{ID: w.ID, X: 5, Y: 5}
166+ if ev := next(t, c, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventConfined) {
167+ t.Fatalf("confine event %v", ev)
168+ }
169+ want := ports.PointerConstrained{ID: w.ID, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 2, Y: 3, W: 23, H: 20}}}
170+ if got := constrained(t, events); got != want {
171+ t.Fatalf("constrained %+v, want %+v", got, want)
172+ }
173+
174+ // A geometry change moves the window-local region.
175+ requestProtocol(t, c, xdgID, xdgshell.SurfaceRequestSetWindowGeometry, int32(1), int32(1), int32(1), int32(1))
176+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
177+ if got := constrained(t, events); got.Rect != (ports.Rect{X: 1, Y: 2, W: 23, H: 20}) {
178+ t.Fatalf("after geometry %+v", got)
179+ }
180+
181+ // A new region applies on commit.
182+ requestProtocol(t, c, confine, pointerconstraints.ZwpConfinedPointerV1RequestSetRegion, uint32(0))
183+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
184+ if got := constrained(t, events); got.Rect != (ports.Rect{}) {
185+ t.Fatalf("whole surface %+v", got)
186+ }
187+
188+ // A oneshot constraint ends for good with the focus.
189+ commands <- ports.PointerFocus{}
190+ if ev := next(t, c, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventUnconfined) {
191+ t.Fatalf("unconfine event %v", ev)
192+ }
193+ commands <- ports.PointerFocus{ID: w.ID, X: 5, Y: 5}
194+ if err := c.Roundtrip(); err != nil {
195+ t.Fatal(err)
196+ }
197+ select {
198+ case ev := <-confineEvents:
199+ t.Fatalf("oneshot reactivated: %v", ev)
200+ case <-time.After(50 * time.Millisecond):
201+ }
202+
203+ // A second constraint on the surface is a protocol error.
204+ again := c.AllocateID()
205+ registerProtocol(t, c, again)
206+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, again, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot))
207+ expectProtocolError(t, c, constraints, uint32(pointerconstraints.ZwpPointerConstraintsV1ErrorAlreadyConstrained))
208+}
209+
210+// Destroying an active lock unlocks the pointer in core.
211+func TestPointerLockDestroyed(t *testing.T) {
212+ s, events, commands, dir := lifecycleServer(t)
213+ c := protocolClient(t, s, dir)
214+ seat := bindProtocol(t, c, "wl_seat")
215+ registerProtocol(t, c, seat)
216+ pointer := c.AllocateID()
217+ registerProtocol(t, c, pointer)
218+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
219+ w, surf, _ := surfaceMapper(t, c, events)()
220+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
221+ lock := c.AllocateID()
222+ lockEvents := logProxy(t, c, lock)
223+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
224+ commands <- ports.PointerFocus{ID: w.ID}
225+ commands <- ports.FocusWindow{ID: w.ID}
226+ next(t, c, lockEvents)
227+ if got := constrained(t, events); got.Mode != ports.ConstraintLock {
228+ t.Fatalf("constrained %+v", got)
229+ }
230+ requestProtocol(t, c, lock, pointerconstraints.ZwpLockedPointerV1RequestDestroy)
231+ if got := constrained(t, events); got.ID != 0 {
232+ t.Fatalf("after destroy %+v", got)
233+ }
234+}
@@ -0,0 +1,234 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
9+ "github.com/bnema/purego-libwayland/protocol/relativepointer"
10+ "github.com/bnema/purego-libwayland/protocol/wayland"
11+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
12+ "github.com/bnema/wlturbo"
13+)
14+
15+// eventLog records the opcodes (and first fixed args) a proxy receives.
16+type eventLog struct {
17+ wlturbo.BaseProxy
18+ events chan []float64 // opcode, then fixed args for relative motion
19+}
20+
21+func (p *eventLog) Dispatch(e *wlturbo.Event) {
22+ ev := []float64{float64(e.Opcode)}
23+ if e.Opcode == uint16(relativepointer.ZwpRelativePointerV1EventRelativeMotion) && e.ProxyID == p.ID() {
24+ e.Uint32()
25+ e.Uint32()
26+ for range 4 {
27+ ev = append(ev, e.Fixed().Float64())
28+ }
29+ }
30+ p.events <- ev
31+}
32+
33+func logProxy(t *testing.T, c *wlturbo.Display, id uint32) chan []float64 {
34+ t.Helper()
35+ p := &eventLog{events: make(chan []float64, 64)}
36+ p.SetID(id)
37+ c.Context().Register(p)
38+ return p.events
39+}
40+
41+// next dispatches until the proxy receives an event.
42+func next(t *testing.T, c *wlturbo.Display, events chan []float64) []float64 {
43+ t.Helper()
44+ deadline := time.Now().Add(2 * time.Second)
45+ for time.Now().Before(deadline) {
46+ select {
47+ case ev := <-events:
48+ return ev
49+ default:
50+ }
51+ if err := c.Roundtrip(); err != nil {
52+ t.Fatal(err)
53+ }
54+ }
55+ t.Fatal("no event")
56+ return nil
57+}
58+
59+func constrained(t *testing.T, events <-chan ports.ClientEvent) ports.PointerConstrained {
60+ t.Helper()
61+ for {
62+ if v, ok := waitEvent(t, events, 2*time.Second).(ports.PointerConstrained); ok {
63+ return v
64+ }
65+ }
66+}
67+
68+func TestPointerLockAndRelativeMotion(t *testing.T) {
69+ s, events, commands, dir := lifecycleServer(t)
70+ c := protocolClient(t, s, dir)
71+ seat := bindProtocol(t, c, "wl_seat")
72+ registerProtocol(t, c, seat)
73+ pointer := c.AllocateID()
74+ pointerEvents := logProxy(t, c, pointer)
75+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
76+ relManager := bindProtocol(t, c, "zwp_relative_pointer_manager_v1")
77+ rel := c.AllocateID()
78+ relEvents := logProxy(t, c, rel)
79+ requestProtocol(t, c, relManager, relativepointer.ZwpRelativePointerManagerV1RequestGetRelativePointer, rel, pointer)
80+ w, surf, _ := surfaceMapper(t, c, events)()
81+
82+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
83+ lock := c.AllocateID()
84+ lockEvents := logProxy(t, c, lock)
85+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
86+ if err := c.Roundtrip(); err != nil {
87+ t.Fatal(err)
88+ }
89+ select {
90+ case ev := <-lockEvents:
91+ t.Fatalf("locked without focus: %v", ev)
92+ default:
93+ }
94+
95+ // Pointer and keyboard focus activate the lock.
96+ commands <- ports.PointerFocus{ID: w.ID}
97+ commands <- ports.FocusWindow{ID: w.ID}
98+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
99+ t.Fatalf("lock event %v", ev)
100+ }
101+ if got := constrained(t, events); got.ID != w.ID || got.Mode != ports.ConstraintLock {
102+ t.Fatalf("constrained %+v", got)
103+ }
104+ for len(pointerEvents) > 0 {
105+ <-pointerEvents
106+ }
107+
108+ // Locked: relative motion flows, absolute motion does not.
109+ commands <- ports.PointerMotionTo{ID: w.ID, X: 5, Y: 5, DX: 1.5, DY: -2, UnaccelDX: 3, UnaccelDY: -4}
110+ if ev := next(t, c, relEvents); len(ev) != 5 || ev[1] != 1.5 || ev[2] != -2 || ev[3] != 3 || ev[4] != -4 {
111+ t.Fatalf("relative motion %v", ev)
112+ }
113+ for len(pointerEvents) > 0 {
114+ if ev := <-pointerEvents; ev[0] == float64(wayland.PointerEventMotion) {
115+ t.Fatal("motion sent to a locked pointer")
116+ }
117+ }
118+
119+ // Losing focus unlocks; a persistent lock comes back.
120+ commands <- ports.FocusWindow{}
121+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventUnlocked) {
122+ t.Fatalf("unlock event %v", ev)
123+ }
124+ if got := constrained(t, events); got.ID != 0 {
125+ t.Fatalf("constrained %+v", got)
126+ }
127+ commands <- ports.FocusWindow{ID: w.ID}
128+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
129+ t.Fatalf("relock event %v", ev)
130+ }
131+}
132+
133+func TestPointerConfineOneshotRegion(t *testing.T) {
134+ s, events, commands, dir := lifecycleServer(t)
135+ c := protocolClient(t, s, dir)
136+ seat := bindProtocol(t, c, "wl_seat")
137+ registerProtocol(t, c, seat)
138+ pointer := c.AllocateID()
139+ pointerEvents := logProxy(t, c, pointer)
140+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
141+ comp := bindProtocol(t, c, "wl_compositor")
142+ w, surf, xdgID := surfaceMapper(t, c, events)()
143+ region := c.AllocateID()
144+ registerProtocol(t, c, region)
145+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
146+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(2), int32(3), int32(10), int32(10))
147+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(20), int32(3), int32(5), int32(20))
148+
149+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
150+ confine := c.AllocateID()
151+ confineEvents := logProxy(t, c, confine)
152+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestConfinePointer, confine, surf, pointer, region, uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot))
153+ // The region is copied: changing it later has no effect.
154+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(0), int32(0), int32(100), int32(100))
155+ // Outside the region the constraint waits for the pointer.
156+ commands <- ports.FocusWindow{ID: w.ID}
157+ commands <- ports.PointerFocus{ID: w.ID}
158+ commands <- ports.PointerMotionTo{ID: w.ID, X: 50, Y: 50}
159+ // Sync on the motion reaching the client before checking.
160+ for ev := next(t, c, pointerEvents); ev[0] != float64(wayland.PointerEventMotion); ev = next(t, c, pointerEvents) {
161+ }
162+ if len(confineEvents) > 0 {
163+ t.Fatal("confined outside the region")
164+ }
165+ commands <- ports.PointerMotionTo{ID: w.ID, X: 5, Y: 5}
166+ if ev := next(t, c, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventConfined) {
167+ t.Fatalf("confine event %v", ev)
168+ }
169+ want := ports.PointerConstrained{ID: w.ID, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 2, Y: 3, W: 23, H: 20}}}
170+ if got := constrained(t, events); got != want {
171+ t.Fatalf("constrained %+v, want %+v", got, want)
172+ }
173+
174+ // A geometry change moves the window-local region.
175+ requestProtocol(t, c, xdgID, xdgshell.SurfaceRequestSetWindowGeometry, int32(1), int32(1), int32(1), int32(1))
176+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
177+ if got := constrained(t, events); got.Rect != (ports.Rect{X: 1, Y: 2, W: 23, H: 20}) {
178+ t.Fatalf("after geometry %+v", got)
179+ }
180+
181+ // A new region applies on commit.
182+ requestProtocol(t, c, confine, pointerconstraints.ZwpConfinedPointerV1RequestSetRegion, uint32(0))
183+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
184+ if got := constrained(t, events); got.Rect != (ports.Rect{}) {
185+ t.Fatalf("whole surface %+v", got)
186+ }
187+
188+ // A oneshot constraint ends for good with the focus.
189+ commands <- ports.PointerFocus{}
190+ if ev := next(t, c, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventUnconfined) {
191+ t.Fatalf("unconfine event %v", ev)
192+ }
193+ commands <- ports.PointerFocus{ID: w.ID, X: 5, Y: 5}
194+ if err := c.Roundtrip(); err != nil {
195+ t.Fatal(err)
196+ }
197+ select {
198+ case ev := <-confineEvents:
199+ t.Fatalf("oneshot reactivated: %v", ev)
200+ case <-time.After(50 * time.Millisecond):
201+ }
202+
203+ // A second constraint on the surface is a protocol error.
204+ again := c.AllocateID()
205+ registerProtocol(t, c, again)
206+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, again, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot))
207+ expectProtocolError(t, c, constraints, uint32(pointerconstraints.ZwpPointerConstraintsV1ErrorAlreadyConstrained))
208+}
209+
210+// Destroying an active lock unlocks the pointer in core.
211+func TestPointerLockDestroyed(t *testing.T) {
212+ s, events, commands, dir := lifecycleServer(t)
213+ c := protocolClient(t, s, dir)
214+ seat := bindProtocol(t, c, "wl_seat")
215+ registerProtocol(t, c, seat)
216+ pointer := c.AllocateID()
217+ registerProtocol(t, c, pointer)
218+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
219+ w, surf, _ := surfaceMapper(t, c, events)()
220+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
221+ lock := c.AllocateID()
222+ lockEvents := logProxy(t, c, lock)
223+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
224+ commands <- ports.PointerFocus{ID: w.ID}
225+ commands <- ports.FocusWindow{ID: w.ID}
226+ next(t, c, lockEvents)
227+ if got := constrained(t, events); got.Mode != ports.ConstraintLock {
228+ t.Fatalf("constrained %+v", got)
229+ }
230+ requestProtocol(t, c, lock, pointerconstraints.ZwpLockedPointerV1RequestDestroy)
231+ if got := constrained(t, events); got.ID != 0 {
232+ t.Fatalf("after destroy %+v", got)
233+ }
234+}
M internal/adapters/wayland/globals.go +23 -6
@@ -16,6 +16,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
1616 func() error { return registerScale(d, s) },
1717 func() error { return registerCursorShape(d, s) },
1818 func() error { return registerDMABuf(d, s, o.DMABuf) },
19+ func() error { return registerPointerConstraints(d, s) },
1920 func() error {
2021 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
2122 },
@@ -68,8 +69,12 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
6869 w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }
6970 }
7071 }
71-func (compositor) CreateRegion(r *wayland.Compositor, id uint32) {
72- wayland.NewRegion(r.Client(), r.Version(), id, region{})
72+func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {
73+ state := &region{}
74+ if w, err := wayland.NewRegion(r.Client(), r.Version(), id, state); err == nil {
75+ c.server.regions[w.Resource] = state
76+ w.OnDestroy = func() { delete(c.server.regions, w.Resource) }
77+ }
7378 }
7479 func (compositor) Release(*wayland.Compositor) {}
7580
@@ -195,11 +200,23 @@ func sortBelowFirst(list []*surface) {
195200 copy(list, append(below, above...))
196201 }
197202
198-type region struct{}
203+// region keeps the bounding box of the rectangles added: pointer
204+// confinement is the only user, and subtracted areas are ignored.
205+type region struct{ box ports.Rect }
199206
200-func (region) Destroy(*wayland.Region) {}
201-func (region) Add(*wayland.Region, int32, int32, int32, int32) {}
202-func (region) Subtract(*wayland.Region, int32, int32, int32, int32) {}
207+func (*region) Destroy(*wayland.Region) {}
208+func (g *region) Add(_ *wayland.Region, x, y, w, h int32) {
209+ if w <= 0 || h <= 0 {
210+ return
211+ }
212+ r := ports.Rect{X: int(x), Y: int(y), W: int(w), H: int(h)}
213+ if g.box.W > 0 {
214+ x0, y0 := min(g.box.X, r.X), min(g.box.Y, r.Y)
215+ r = ports.Rect{X: x0, Y: y0, W: max(g.box.X+g.box.W, r.X+r.W) - x0, H: max(g.box.Y+g.box.H, r.Y+r.H) - y0}
216+ }
217+ g.box = r
218+}
219+func (*region) Subtract(*wayland.Region, int32, int32, int32, int32) {}
203220
204221 type shm struct{ server *Server }
205222
@@ -16,6 +16,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
16 func() error { return registerScale(d, s) },16 func() error { return registerScale(d, s) },
17 func() error { return registerCursorShape(d, s) },17 func() error { return registerCursorShape(d, s) },
18 func() error { return registerDMABuf(d, s, o.DMABuf) },18 func() error { return registerDMABuf(d, s, o.DMABuf) },
19+ func() error { return registerPointerConstraints(d, s) },
19 func() error {20 func() error {
20 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })21 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
21 },22 },
@@ -68,8 +69,12 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
68 w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }69 w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }
69 }70 }
70 }71 }
71-func (compositor) CreateRegion(r *wayland.Compositor, id uint32) {72+func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {
72- wayland.NewRegion(r.Client(), r.Version(), id, region{})73+ state := &region{}
74+ if w, err := wayland.NewRegion(r.Client(), r.Version(), id, state); err == nil {
75+ c.server.regions[w.Resource] = state
76+ w.OnDestroy = func() { delete(c.server.regions, w.Resource) }
77+ }
73 }78 }
74 func (compositor) Release(*wayland.Compositor) {}79 func (compositor) Release(*wayland.Compositor) {}
75 80
@@ -195,11 +200,23 @@ func sortBelowFirst(list []*surface) {
195 copy(list, append(below, above...))200 copy(list, append(below, above...))
196 }201 }
197 202
198-type region struct{}203+// region keeps the bounding box of the rectangles added: pointer
204+// confinement is the only user, and subtracted areas are ignored.
205+type region struct{ box ports.Rect }
199 206
200-func (region) Destroy(*wayland.Region) {}207+func (*region) Destroy(*wayland.Region) {}
201-func (region) Add(*wayland.Region, int32, int32, int32, int32) {}208+func (g *region) Add(_ *wayland.Region, x, y, w, h int32) {
202-func (region) Subtract(*wayland.Region, int32, int32, int32, int32) {}209+ if w <= 0 || h <= 0 {
210+ return
211+ }
212+ r := ports.Rect{X: int(x), Y: int(y), W: int(w), H: int(h)}
213+ if g.box.W > 0 {
214+ x0, y0 := min(g.box.X, r.X), min(g.box.Y, r.Y)
215+ r = ports.Rect{X: x0, Y: y0, W: max(g.box.X+g.box.W, r.X+r.W) - x0, H: max(g.box.Y+g.box.H, r.Y+r.H) - y0}
216+ }
217+ g.box = r
218+}
219+func (*region) Subtract(*wayland.Region, int32, int32, int32, int32) {}
203 220
204 type shm struct{ server *Server }221 type shm struct{ server *Server }
205 222
M internal/adapters/wayland/protocol_test.go +10 -2
@@ -590,6 +590,14 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
590590 // toplevelMapper returns a function that maps a new 1x1 xdg toplevel on c and
591591 // returns the WindowMapped event it produced.
592592 func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() ports.WindowMapped {
593+ t.Helper()
594+ m := surfaceMapper(t, c, events)
595+ return func() ports.WindowMapped { w, _, _ := m(); return w }
596+}
597+
598+// surfaceMapper is toplevelMapper also returning the wl_surface and
599+// xdg_surface IDs.
600+func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() (ports.WindowMapped, uint32, uint32) {
593601 t.Helper()
594602 comp := bindProtocol(t, c, "wl_compositor")
595603 wm := bindProtocol(t, c, "xdg_wm_base")
@@ -610,7 +618,7 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client
610618 t.Fatal(err)
611619 }
612620 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
613- return func() ports.WindowMapped {
621+ return func() (ports.WindowMapped, uint32, uint32) {
614622 surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
615623 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
616624 registerProtocol(t, c, surf)
@@ -636,6 +644,6 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client
636644 if err := c.Roundtrip(); err != nil {
637645 t.Fatal(err)
638646 }
639- return mapped(t, events, 2*time.Second)
647+ return mapped(t, events, 2*time.Second), surf, xdg
640648 }
641649 }
@@ -590,6 +590,14 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
590 // toplevelMapper returns a function that maps a new 1x1 xdg toplevel on c and590 // toplevelMapper returns a function that maps a new 1x1 xdg toplevel on c and
591 // returns the WindowMapped event it produced.591 // returns the WindowMapped event it produced.
592 func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() ports.WindowMapped {592 func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() ports.WindowMapped {
593+ t.Helper()
594+ m := surfaceMapper(t, c, events)
595+ return func() ports.WindowMapped { w, _, _ := m(); return w }
596+}
597+
598+// surfaceMapper is toplevelMapper also returning the wl_surface and
599+// xdg_surface IDs.
600+func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() (ports.WindowMapped, uint32, uint32) {
593 t.Helper()601 t.Helper()
594 comp := bindProtocol(t, c, "wl_compositor")602 comp := bindProtocol(t, c, "wl_compositor")
595 wm := bindProtocol(t, c, "xdg_wm_base")603 wm := bindProtocol(t, c, "xdg_wm_base")
@@ -610,7 +618,7 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client
610 t.Fatal(err)618 t.Fatal(err)
611 }619 }
612 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))620 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
613- return func() ports.WindowMapped {621+ return func() (ports.WindowMapped, uint32, uint32) {
614 surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()622 surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
615 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)623 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
616 registerProtocol(t, c, surf)624 registerProtocol(t, c, surf)
@@ -636,6 +644,6 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client
636 if err := c.Roundtrip(); err != nil {644 if err := c.Roundtrip(); err != nil {
637 t.Fatal(err)645 t.Fatal(err)
638 }646 }
639- return mapped(t, events, 2*time.Second)647+ return mapped(t, events, 2*time.Second), surf, xdg
640 }648 }
641 }649 }
M internal/adapters/wayland/server.go +23 -1
@@ -4,6 +4,7 @@ import (
44 "context"
55 "encoding/binary"
66 "fmt"
7+ "github.com/bnema/purego-libwayland/protocol/relativepointer"
78 "os"
89 "sync"
910 "time"
@@ -92,6 +93,11 @@ type Server struct {
9293 selections [2]*clipSource
9394 clipDevices []*clipDevice
9495 dataSources, primarySources, controlSources map[*server.Resource]*clipSource
96+ // Pointer constraints (constraints.go).
97+ regions map[*server.Resource]*region
98+ relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1
99+ constraints map[*surface]*constraint
100+ constraint *constraint // the active one
95101 }
96102
97103 func removeItem[T comparable](list []T, v T) []T {
@@ -125,7 +131,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
125131 d.Close()
126132 return nil, err
127133 }
128- s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
134+ s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
129135 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
130136 if opts.Keymap != "" {
131137 fd, size, e := keymapFile(opts.Keymap)
@@ -361,7 +367,21 @@ func (s *Server) apply(cmd ports.ClientCommand) {
361367 s.changePointerFocus(c.ID, c.X, c.Y)
362368 case ports.PointerMotionTo:
363369 if w := s.windows[c.ID]; w != nil && c.ID == s.pointerFocus {
370+ if s.relativeMotion(w, c) && s.locked(w) {
371+ for _, p := range s.windowPointers(w) {
372+ pointerFrame(p)
373+ }
374+ }
375+ if s.locked(w) {
376+ // Only relative motion reaches a locked pointer.
377+ return
378+ }
364379 x, y := w.surfacePoint(c.X, c.Y)
380+ s.pointerX, s.pointerY = c.X, c.Y
381+ // A constraint waits for the pointer to enter its region.
382+ if s.constraint == nil {
383+ s.updateConstraint()
384+ }
365385 for _, p := range s.windowPointers(w) {
366386 p.SendMotion(c.TimeMsec, server.FixedFromFloat(x), server.FixedFromFloat(y))
367387 pointerFrame(p)
@@ -566,6 +586,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
566586 }
567587 }
568588 s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")
589+ s.updateConstraint()
569590 }
570591
571592 func pointerFrame(p *wayland.Pointer) {
@@ -613,4 +634,5 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
613634 }
614635 }
615636 }
637+ s.updateConstraint()
616638 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "encoding/binary"5 "encoding/binary"
6 "fmt"6 "fmt"
7+ "github.com/bnema/purego-libwayland/protocol/relativepointer"
7 "os"8 "os"
8 "sync"9 "sync"
9 "time"10 "time"
@@ -92,6 +93,11 @@ type Server struct {
92 selections [2]*clipSource93 selections [2]*clipSource
93 clipDevices []*clipDevice94 clipDevices []*clipDevice
94 dataSources, primarySources, controlSources map[*server.Resource]*clipSource95 dataSources, primarySources, controlSources map[*server.Resource]*clipSource
96+ // Pointer constraints (constraints.go).
97+ regions map[*server.Resource]*region
98+ relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1
99+ constraints map[*surface]*constraint
100+ constraint *constraint // the active one
95 }101 }
96 102
97 func removeItem[T comparable](list []T, v T) []T {103 func removeItem[T comparable](list []T, v T) []T {
@@ -125,7 +131,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
125 d.Close()131 d.Close()
126 return nil, err132 return nil, err
127 }133 }
128- s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}134+ s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
129 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}135 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
130 if opts.Keymap != "" {136 if opts.Keymap != "" {
131 fd, size, e := keymapFile(opts.Keymap)137 fd, size, e := keymapFile(opts.Keymap)
@@ -361,7 +367,21 @@ func (s *Server) apply(cmd ports.ClientCommand) {
361 s.changePointerFocus(c.ID, c.X, c.Y)367 s.changePointerFocus(c.ID, c.X, c.Y)
362 case ports.PointerMotionTo:368 case ports.PointerMotionTo:
363 if w := s.windows[c.ID]; w != nil && c.ID == s.pointerFocus {369 if w := s.windows[c.ID]; w != nil && c.ID == s.pointerFocus {
370+ if s.relativeMotion(w, c) && s.locked(w) {
371+ for _, p := range s.windowPointers(w) {
372+ pointerFrame(p)
373+ }
374+ }
375+ if s.locked(w) {
376+ // Only relative motion reaches a locked pointer.
377+ return
378+ }
364 x, y := w.surfacePoint(c.X, c.Y)379 x, y := w.surfacePoint(c.X, c.Y)
380+ s.pointerX, s.pointerY = c.X, c.Y
381+ // A constraint waits for the pointer to enter its region.
382+ if s.constraint == nil {
383+ s.updateConstraint()
384+ }
365 for _, p := range s.windowPointers(w) {385 for _, p := range s.windowPointers(w) {
366 p.SendMotion(c.TimeMsec, server.FixedFromFloat(x), server.FixedFromFloat(y))386 p.SendMotion(c.TimeMsec, server.FixedFromFloat(x), server.FixedFromFloat(y))
367 pointerFrame(p)387 pointerFrame(p)
@@ -566,6 +586,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
566 }586 }
567 }587 }
568 s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")588 s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")
589+ s.updateConstraint()
569 }590 }
570 591
571 func pointerFrame(p *wayland.Pointer) {592 func pointerFrame(p *wayland.Pointer) {
@@ -613,4 +634,5 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
613 }634 }
614 }635 }
615 }636 }
637+ s.updateConstraint()
616 }638 }
M internal/adapters/wayland/surface.go +4 -0
@@ -157,6 +157,9 @@ func (s *surface) Destroy(*wayland.Surface) {
157157 if s.role != nil {
158158 s.role(false)
159159 }
160+ if c := s.server.constraints[s]; c != nil {
161+ s.server.dropConstraint(c)
162+ }
160163 }
161164 func (s *surface) Attach(_ *wayland.Surface, b *wayland.Buffer, _, _ int32) {
162165 s.pending = b
@@ -230,6 +233,7 @@ func (s *surface) Commit(*wayland.Surface) {
230233 if s.xdg != nil && s.xdg.pendingGeometry != s.xdg.geometry {
231234 s.xdg.geometry, geometry = s.xdg.pendingGeometry, true
232235 }
236+ s.commitConstraint(geometry)
233237 if fresh || moved || geometry || s.sub.parent != nil {
234238 s.redraw()
235239 }
@@ -157,6 +157,9 @@ func (s *surface) Destroy(*wayland.Surface) {
157 if s.role != nil {157 if s.role != nil {
158 s.role(false)158 s.role(false)
159 }159 }
160+ if c := s.server.constraints[s]; c != nil {
161+ s.server.dropConstraint(c)
162+ }
160 }163 }
161 func (s *surface) Attach(_ *wayland.Surface, b *wayland.Buffer, _, _ int32) {164 func (s *surface) Attach(_ *wayland.Surface, b *wayland.Buffer, _, _ int32) {
162 s.pending = b165 s.pending = b
@@ -230,6 +233,7 @@ func (s *surface) Commit(*wayland.Surface) {
230 if s.xdg != nil && s.xdg.pendingGeometry != s.xdg.geometry {233 if s.xdg != nil && s.xdg.pendingGeometry != s.xdg.geometry {
231 s.xdg.geometry, geometry = s.xdg.pendingGeometry, true234 s.xdg.geometry, geometry = s.xdg.pendingGeometry, true
232 }235 }
236+ s.commitConstraint(geometry)
233 if fresh || moved || geometry || s.sub.parent != nil {237 if fresh || moved || geometry || s.sub.parent != nil {
234 s.redraw()238 s.redraw()
235 }239 }
M internal/app/app.go +3 -2
@@ -74,12 +74,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
7474 spawn := make(chan ports.SpawnRequest, 32)
7575 scenes := make(chan []ports.Scene, 1)
7676 layouts := make(chan ports.Layout, 1)
77+ constraints := make(chan ports.PointerConstraint, 1)
7778 states := make(chan ports.State, 1)
7879 configErrors := make(chan error, 8)
7980 renderScenes := make(chan []ports.Scene, 1)
8081 contents := make(chan ports.SurfaceContent, 64)
8182 cursorChanges := make(chan ports.CursorChange, 1)
82- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
83+ ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
8384 c, err := core.New(opts.Config, ch)
8485 if err != nil {
8586 return err
@@ -142,7 +143,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
142143 return
143144 }
144145 done <- safe("input", func() error {
145- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input")}, input)
146+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input")}, input)
146147 })
147148 return
148149 }
@@ -74,12 +74,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
74 spawn := make(chan ports.SpawnRequest, 32)74 spawn := make(chan ports.SpawnRequest, 32)
75 scenes := make(chan []ports.Scene, 1)75 scenes := make(chan []ports.Scene, 1)
76 layouts := make(chan ports.Layout, 1)76 layouts := make(chan ports.Layout, 1)
77+ constraints := make(chan ports.PointerConstraint, 1)
77 states := make(chan ports.State, 1)78 states := make(chan ports.State, 1)
78 configErrors := make(chan error, 8)79 configErrors := make(chan error, 8)
79 renderScenes := make(chan []ports.Scene, 1)80 renderScenes := make(chan []ports.Scene, 1)
80 contents := make(chan ports.SurfaceContent, 64)81 contents := make(chan ports.SurfaceContent, 64)
81 cursorChanges := make(chan ports.CursorChange, 1)82 cursorChanges := make(chan ports.CursorChange, 1)
82- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}83+ ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
83 c, err := core.New(opts.Config, ch)84 c, err := core.New(opts.Config, ch)
84 if err != nil {85 if err != nil {
85 return err86 return err
@@ -142,7 +143,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
142 return143 return
143 }144 }
144 done <- safe("input", func() error {145 done <- safe("input", func() error {
145- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input")}, input)146+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input")}, input)
146 })147 })
147 return148 return
148 }149 }
M internal/core/core.go +57 -6
@@ -26,7 +26,11 @@ type Channels struct {
2626 Scenes chan []ports.Scene
2727 // Layouts, when set, receives the output layout whenever it changes,
2828 // latest first (capacity 1, drained like Scenes). Input and cursors use it.
29- Layouts chan ports.Layout
29+ Layouts chan ports.Layout
30+ // Constraints, when set, receives the active pointer constraint in
31+ // global logical coordinates whenever it changes (capacity 1, drained
32+ // like Scenes). Input applies it to the pointer.
33+ Constraints chan ports.PointerConstraint
3034 ConfigErrors chan<- error
3135 // State, when set, receives a snapshot for scripts whenever it changes,
3236 // latest first (capacity 1, drained like Scenes).
@@ -53,7 +57,11 @@ type Core struct {
5357 buttons map[uint32]bool
5458 cursorX, cursorY float64 // global, logical
5559 pointerOutput string // output under the pointer at the last motion
56- seq uint64
60+ // constrained is the constraint wayland activated; constraint is it
61+ // resolved to global coordinates, as last sent to input.
62+ constrained ports.PointerConstrained
63+ constraint ports.PointerConstraint
64+ seq uint64
5765 // layerChanged is set once layer state arrives from wayland.
5866 layerChanged bool
5967 sentOutputs ports.SetOutputs
@@ -248,8 +256,8 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
248256 return nil
249257 }
250258 func New(cfg ports.Config, ch Channels) (*Core, error) {
251- if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.State != nil && cap(ch.State) != 1) {
252- return nil, fmt.Errorf("scenes, layouts and state must have capacity 1")
259+ if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) {
260+ return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")
253261 }
254262 // A placeholder screen holds windows until the first output arrives.
255263 c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
@@ -406,11 +414,49 @@ func (c *Core) publish(ctx context.Context) error {
406414 }
407415 c.focus = focus
408416 }
417+ c.resolveConstraint()
409418 latest(c.ch.Scenes, scenes)
410419 c.publishState()
411420 return nil
412421 }
413422
423+// resolveConstraint turns the active constraint into global coordinates
424+// and sends it to input when it changed. A hidden window holds nothing.
425+func (c *Core) resolveConstraint() {
426+ var g ports.PointerConstraint
427+ if id := c.constrained.ID; id != 0 {
428+ if s, _ := c.screenOf(id); s != nil {
429+ for _, p := range s.mon.Layout() {
430+ if p.ID != id || p.Hidden {
431+ continue
432+ }
433+ r := c.clientRect(p)
434+ r.X += s.x
435+ if w := c.constrained.Rect; w.W > 0 && w.H > 0 {
436+ // The region is clipped to the window.
437+ x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y)
438+ x1, y1 := min(r.X+r.W, r.X+w.X+w.W), min(r.Y+r.H, r.Y+w.Y+w.H)
439+ if x1 > x0 && y1 > y0 {
440+ r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
441+ }
442+ }
443+ g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: r}
444+ }
445+ }
446+ }
447+ // The cursor follows core; input resyncs to it only on a change.
448+ if g.Mode == c.constraint.Mode && g.Rect == c.constraint.Rect {
449+ return
450+ }
451+ // A layout change must not leave a locked cursor off its window.
452+ c.cursorX, c.cursorY = g.Clamp(c.cursorX, c.cursorY)
453+ g.X, g.Y = c.cursorX, c.cursorY
454+ c.constraint = g
455+ if c.ch.Constraints != nil {
456+ latest(c.ch.Constraints, g)
457+ }
458+}
459+
414460 // latest replaces any unread value: only the owner sends and drains;
415461 // consumers only receive.
416462 func latest[T any](ch chan T, v T) {
@@ -517,6 +563,8 @@ func (c *Core) Run(ctx context.Context) error {
517563 return nil
518564 }
519565 }
566+ case ports.PointerConstrained:
567+ c.constrained = v
520568 case ports.WindowFullscreenRequest:
521569 if s, _ := c.screenOf(v.ID); s != nil {
522570 s.mon.SetFullscreen(v.ID, v.Fullscreen)
@@ -571,7 +619,10 @@ func (c *Core) Run(ctx context.Context) error {
571619 }
572620 switch v := ev.(type) {
573621 case ports.PointerMotion:
574- c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)
622+ // A locked pointer stays still; relative motion still flows.
623+ if c.constraint.Mode != ports.ConstraintLock {
624+ c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y))
625+ }
575626 // The focused screen follows the pointer, so new windows
576627 // and launchers open where the user is.
577628 // Only a pointer entering another output switches: keyboard
@@ -594,7 +645,7 @@ func (c *Core) Run(ctx context.Context) error {
594645 }
595646 }
596647 if id != 0 {
597- if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: v.TimeMsec}); err != nil {
648+ if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}); err != nil {
598649 return nil
599650 }
600651 }
@@ -26,7 +26,11 @@ type Channels struct {
26 Scenes chan []ports.Scene26 Scenes chan []ports.Scene
27 // Layouts, when set, receives the output layout whenever it changes,27 // Layouts, when set, receives the output layout whenever it changes,
28 // latest first (capacity 1, drained like Scenes). Input and cursors use it.28 // latest first (capacity 1, drained like Scenes). Input and cursors use it.
29- Layouts chan ports.Layout29+ Layouts chan ports.Layout
30+ // Constraints, when set, receives the active pointer constraint in
31+ // global logical coordinates whenever it changes (capacity 1, drained
32+ // like Scenes). Input applies it to the pointer.
33+ Constraints chan ports.PointerConstraint
30 ConfigErrors chan<- error34 ConfigErrors chan<- error
31 // State, when set, receives a snapshot for scripts whenever it changes,35 // State, when set, receives a snapshot for scripts whenever it changes,
32 // latest first (capacity 1, drained like Scenes).36 // latest first (capacity 1, drained like Scenes).
@@ -53,7 +57,11 @@ type Core struct {
53 buttons map[uint32]bool57 buttons map[uint32]bool
54 cursorX, cursorY float64 // global, logical58 cursorX, cursorY float64 // global, logical
55 pointerOutput string // output under the pointer at the last motion59 pointerOutput string // output under the pointer at the last motion
56- seq uint6460+ // constrained is the constraint wayland activated; constraint is it
61+ // resolved to global coordinates, as last sent to input.
62+ constrained ports.PointerConstrained
63+ constraint ports.PointerConstraint
64+ seq uint64
57 // layerChanged is set once layer state arrives from wayland.65 // layerChanged is set once layer state arrives from wayland.
58 layerChanged bool66 layerChanged bool
59 sentOutputs ports.SetOutputs67 sentOutputs ports.SetOutputs
@@ -248,8 +256,8 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
248 return nil256 return nil
249 }257 }
250 func New(cfg ports.Config, ch Channels) (*Core, error) {258 func New(cfg ports.Config, ch Channels) (*Core, error) {
251- if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.State != nil && cap(ch.State) != 1) {259+ if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) {
252- return nil, fmt.Errorf("scenes, layouts and state must have capacity 1")260+ return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")
253 }261 }
254 // A placeholder screen holds windows until the first output arrives.262 // A placeholder screen holds windows until the first output arrives.
255 c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}263 c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
@@ -406,11 +414,49 @@ func (c *Core) publish(ctx context.Context) error {
406 }414 }
407 c.focus = focus415 c.focus = focus
408 }416 }
417+ c.resolveConstraint()
409 latest(c.ch.Scenes, scenes)418 latest(c.ch.Scenes, scenes)
410 c.publishState()419 c.publishState()
411 return nil420 return nil
412 }421 }
413 422
423+// resolveConstraint turns the active constraint into global coordinates
424+// and sends it to input when it changed. A hidden window holds nothing.
425+func (c *Core) resolveConstraint() {
426+ var g ports.PointerConstraint
427+ if id := c.constrained.ID; id != 0 {
428+ if s, _ := c.screenOf(id); s != nil {
429+ for _, p := range s.mon.Layout() {
430+ if p.ID != id || p.Hidden {
431+ continue
432+ }
433+ r := c.clientRect(p)
434+ r.X += s.x
435+ if w := c.constrained.Rect; w.W > 0 && w.H > 0 {
436+ // The region is clipped to the window.
437+ x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y)
438+ x1, y1 := min(r.X+r.W, r.X+w.X+w.W), min(r.Y+r.H, r.Y+w.Y+w.H)
439+ if x1 > x0 && y1 > y0 {
440+ r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
441+ }
442+ }
443+ g = ports.PointerConstraint{Mode: c.constrained.Mode, Rect: r}
444+ }
445+ }
446+ }
447+ // The cursor follows core; input resyncs to it only on a change.
448+ if g.Mode == c.constraint.Mode && g.Rect == c.constraint.Rect {
449+ return
450+ }
451+ // A layout change must not leave a locked cursor off its window.
452+ c.cursorX, c.cursorY = g.Clamp(c.cursorX, c.cursorY)
453+ g.X, g.Y = c.cursorX, c.cursorY
454+ c.constraint = g
455+ if c.ch.Constraints != nil {
456+ latest(c.ch.Constraints, g)
457+ }
458+}
459+
414 // latest replaces any unread value: only the owner sends and drains;460 // latest replaces any unread value: only the owner sends and drains;
415 // consumers only receive.461 // consumers only receive.
416 func latest[T any](ch chan T, v T) {462 func latest[T any](ch chan T, v T) {
@@ -517,6 +563,8 @@ func (c *Core) Run(ctx context.Context) error {
517 return nil563 return nil
518 }564 }
519 }565 }
566+ case ports.PointerConstrained:
567+ c.constrained = v
520 case ports.WindowFullscreenRequest:568 case ports.WindowFullscreenRequest:
521 if s, _ := c.screenOf(v.ID); s != nil {569 if s, _ := c.screenOf(v.ID); s != nil {
522 s.mon.SetFullscreen(v.ID, v.Fullscreen)570 s.mon.SetFullscreen(v.ID, v.Fullscreen)
@@ -571,7 +619,10 @@ func (c *Core) Run(ctx context.Context) error {
571 }619 }
572 switch v := ev.(type) {620 switch v := ev.(type) {
573 case ports.PointerMotion:621 case ports.PointerMotion:
574- c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)622+ // A locked pointer stays still; relative motion still flows.
623+ if c.constraint.Mode != ports.ConstraintLock {
624+ c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y))
625+ }
575 // The focused screen follows the pointer, so new windows626 // The focused screen follows the pointer, so new windows
576 // and launchers open where the user is.627 // and launchers open where the user is.
577 // Only a pointer entering another output switches: keyboard628 // Only a pointer entering another output switches: keyboard
@@ -594,7 +645,7 @@ func (c *Core) Run(ctx context.Context) error {
594 }645 }
595 }646 }
596 if id != 0 {647 if id != 0 {
597- if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: v.TimeMsec}); err != nil {648+ if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}); err != nil {
598 return nil649 return nil
599 }650 }
600 }651 }
M internal/core/core_test.go +71 -0
@@ -570,3 +570,74 @@ func TestOutputScale(t *testing.T) {
570570 t.Fatal(s.Scale, s.OutputWidth)
571571 }
572572 }
573+
574+func TestPointerConstraint(t *testing.T) {
575+ cfg := config.Defaults()
576+ cfg.Border.Width = 0
577+ cfg.Layout.Gaps = 0
578+ client := make(chan ports.ClientEvent, 8)
579+ input := make(chan ports.InputEvent, 8)
580+ output := make(chan ports.OutputEvent, 8)
581+ commands := make(chan ports.ClientCommand, 64)
582+ scenes := make(chan []ports.Scene, 1)
583+ constraints := make(chan ports.PointerConstraint, 1)
584+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints})
585+ if err != nil {
586+ t.Fatal(err)
587+ }
588+ ctx, cancel := context.WithCancel(context.Background())
589+ defer cancel()
590+ go c.Run(ctx)
591+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
592+ scene(t, scenes)
593+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}}
594+ scene(t, scenes)
595+ input <- ports.PointerMotion{X: 150, Y: 40}
596+ scene(t, scenes) // the pointer moves the focus to B
597+ client <- ports.WindowMapped{ID: 1}
598+ r := receive(t, scenes)[1].Windows[0].Rect
599+ // The confine region is window-local and clipped to the window.
600+ client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}}
601+ scene(t, scenes)
602+ want := ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 100 + r.X + 10, Y: r.Y + 10, W: r.W - 10, H: 20}}
603+ // Input gets core's cursor, clamped into the region.
604+ if got := receive(t, constraints); got.Mode != want.Mode || got.Rect != want.Rect || got.X != 150 || got.Y != 29 {
605+ t.Fatalf("got %+v, want %+v", got, want)
606+ }
607+ for len(commands) > 0 {
608+ <-commands
609+ }
610+ // Motion is clamped into the region; deltas pass through.
611+ input <- ports.PointerMotion{X: 199, Y: 79, DX: 5, DY: 6}
612+ for {
613+ if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
614+ if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.Y != float64(want.Rect.Y+want.Rect.H-1-r.Y) || v.DX != 5 || v.DY != 6 {
615+ t.Fatalf("motion %+v", v)
616+ }
617+ break
618+ }
619+ }
620+ // A lock keeps the pointer still.
621+ client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintLock}}
622+ scene(t, scenes)
623+ if got := receive(t, constraints); got.Mode != ports.ConstraintLock {
624+ t.Fatalf("lock %+v", got)
625+ }
626+ for len(commands) > 0 {
627+ <-commands
628+ }
629+ input <- ports.PointerMotion{X: 101, Y: 1, DX: -50}
630+ for {
631+ if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
632+ if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.DX != -50 {
633+ t.Fatalf("locked motion %+v", v)
634+ }
635+ break
636+ }
637+ }
638+ client <- ports.PointerConstrained{}
639+ scene(t, scenes)
640+ if got := receive(t, constraints); got.Mode != ports.ConstraintNone {
641+ t.Fatalf("release %+v", got)
642+ }
643+}
@@ -570,3 +570,74 @@ func TestOutputScale(t *testing.T) {
570 t.Fatal(s.Scale, s.OutputWidth)570 t.Fatal(s.Scale, s.OutputWidth)
571 }571 }
572 }572 }
573+
574+func TestPointerConstraint(t *testing.T) {
575+ cfg := config.Defaults()
576+ cfg.Border.Width = 0
577+ cfg.Layout.Gaps = 0
578+ client := make(chan ports.ClientEvent, 8)
579+ input := make(chan ports.InputEvent, 8)
580+ output := make(chan ports.OutputEvent, 8)
581+ commands := make(chan ports.ClientCommand, 64)
582+ scenes := make(chan []ports.Scene, 1)
583+ constraints := make(chan ports.PointerConstraint, 1)
584+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints})
585+ if err != nil {
586+ t.Fatal(err)
587+ }
588+ ctx, cancel := context.WithCancel(context.Background())
589+ defer cancel()
590+ go c.Run(ctx)
591+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
592+ scene(t, scenes)
593+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}}
594+ scene(t, scenes)
595+ input <- ports.PointerMotion{X: 150, Y: 40}
596+ scene(t, scenes) // the pointer moves the focus to B
597+ client <- ports.WindowMapped{ID: 1}
598+ r := receive(t, scenes)[1].Windows[0].Rect
599+ // The confine region is window-local and clipped to the window.
600+ client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}}
601+ scene(t, scenes)
602+ want := ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 100 + r.X + 10, Y: r.Y + 10, W: r.W - 10, H: 20}}
603+ // Input gets core's cursor, clamped into the region.
604+ if got := receive(t, constraints); got.Mode != want.Mode || got.Rect != want.Rect || got.X != 150 || got.Y != 29 {
605+ t.Fatalf("got %+v, want %+v", got, want)
606+ }
607+ for len(commands) > 0 {
608+ <-commands
609+ }
610+ // Motion is clamped into the region; deltas pass through.
611+ input <- ports.PointerMotion{X: 199, Y: 79, DX: 5, DY: 6}
612+ for {
613+ if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
614+ if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.Y != float64(want.Rect.Y+want.Rect.H-1-r.Y) || v.DX != 5 || v.DY != 6 {
615+ t.Fatalf("motion %+v", v)
616+ }
617+ break
618+ }
619+ }
620+ // A lock keeps the pointer still.
621+ client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintLock}}
622+ scene(t, scenes)
623+ if got := receive(t, constraints); got.Mode != ports.ConstraintLock {
624+ t.Fatalf("lock %+v", got)
625+ }
626+ for len(commands) > 0 {
627+ <-commands
628+ }
629+ input <- ports.PointerMotion{X: 101, Y: 1, DX: -50}
630+ for {
631+ if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
632+ if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.DX != -50 {
633+ t.Fatalf("locked motion %+v", v)
634+ }
635+ break
636+ }
637+ }
638+ client <- ports.PointerConstrained{}
639+ scene(t, scenes)
640+ if got := receive(t, constraints); got.Mode != ports.ConstraintNone {
641+ t.Fatalf("release %+v", got)
642+ }
643+}
M internal/ports/messages.go +60 -5
@@ -83,6 +83,49 @@ type LayerChanged struct{ Layers []LayerSurface }
8383
8484 func (LayerChanged) clientEvent() {}
8585
86+// ConstraintMode is how a pointer constraint holds the pointer.
87+type ConstraintMode uint8
88+
89+const (
90+ ConstraintNone ConstraintMode = iota
91+ // ConstraintLock keeps the pointer still: only relative motion flows.
92+ ConstraintLock
93+ // ConstraintConfine keeps the pointer inside Rect.
94+ ConstraintConfine
95+)
96+
97+// PointerConstraint is an active pointer constraint. Rect is logical: in
98+// PointerConstrained it is window-local and empty means the whole window;
99+// core sends input the resolved global rectangle, and in X, Y its cursor
100+// position, where input holds a locked pointer.
101+type PointerConstraint struct {
102+ Mode ConstraintMode
103+ Rect Rect
104+ X, Y float64
105+}
106+
107+// Clamp keeps a global point inside the rectangle of a lock or confine;
108+// no constraint and an empty rectangle leave it unchanged.
109+func (c PointerConstraint) Clamp(x, y float64) (float64, float64) {
110+ if c.Mode == ConstraintNone || c.Rect.W <= 0 || c.Rect.H <= 0 {
111+ return x, y
112+ }
113+ x = min(max(x, float64(c.Rect.X)), float64(c.Rect.X+c.Rect.W-1))
114+ y = min(max(y, float64(c.Rect.Y)), float64(c.Rect.Y+c.Rect.H-1))
115+ return x, y
116+}
117+
118+// PointerConstrained carries wayland → core the constraint active on a
119+// window (zwp_pointer_constraints_v1); ID 0 means none is active. A
120+// constraint is active only on the window with both pointer and keyboard
121+// focus.
122+type PointerConstrained struct {
123+ ID WindowID
124+ PointerConstraint
125+}
126+
127+func (PointerConstrained) clientEvent() {}
128+
86129 // Mods carries input → core modifier flags.
87130 type Mods uint8
88131
@@ -117,9 +160,15 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 }
117160 func (KeyEvent) inputEvent() {}
118161
119162 // PointerMotion uses global layout coordinates in logical pixels (see OutputPlacement).
163+// DX and DY are the accelerated logical deltas and UnaccelDX, UnaccelDY the
164+// raw device deltas; all four are 0 for absolute devices. TimeUsec is the
165+// device timestamp in microseconds.
120166 type PointerMotion struct {
121- X, Y float64
122- TimeMsec uint32
167+ X, Y float64
168+ DX, DY float64
169+ UnaccelDX, UnaccelDY float64
170+ TimeMsec uint32
171+ TimeUsec uint64
123172 }
124173
125174 func (PointerMotion) inputEvent() {}
@@ -263,10 +312,16 @@ type PointerFocus struct {
263312
264313 func (PointerFocus) clientCommand() {}
265314
315+// PointerMotionTo carries core → wayland motion for the pointer focus, in
316+// window coordinates. The deltas and TimeUsec feed zwp_relative_pointer_v1;
317+// While the window locks the pointer, wayland sends only relative motion.
266318 type PointerMotionTo struct {
267- ID WindowID
268- X, Y float64
269- TimeMsec uint32
319+ ID WindowID
320+ X, Y float64
321+ DX, DY float64
322+ UnaccelDX, UnaccelDY float64
323+ TimeMsec uint32
324+ TimeUsec uint64
270325 }
271326
272327 func (PointerMotionTo) clientCommand() {}
@@ -83,6 +83,49 @@ type LayerChanged struct{ Layers []LayerSurface }
83 83
84 func (LayerChanged) clientEvent() {}84 func (LayerChanged) clientEvent() {}
85 85
86+// ConstraintMode is how a pointer constraint holds the pointer.
87+type ConstraintMode uint8
88+
89+const (
90+ ConstraintNone ConstraintMode = iota
91+ // ConstraintLock keeps the pointer still: only relative motion flows.
92+ ConstraintLock
93+ // ConstraintConfine keeps the pointer inside Rect.
94+ ConstraintConfine
95+)
96+
97+// PointerConstraint is an active pointer constraint. Rect is logical: in
98+// PointerConstrained it is window-local and empty means the whole window;
99+// core sends input the resolved global rectangle, and in X, Y its cursor
100+// position, where input holds a locked pointer.
101+type PointerConstraint struct {
102+ Mode ConstraintMode
103+ Rect Rect
104+ X, Y float64
105+}
106+
107+// Clamp keeps a global point inside the rectangle of a lock or confine;
108+// no constraint and an empty rectangle leave it unchanged.
109+func (c PointerConstraint) Clamp(x, y float64) (float64, float64) {
110+ if c.Mode == ConstraintNone || c.Rect.W <= 0 || c.Rect.H <= 0 {
111+ return x, y
112+ }
113+ x = min(max(x, float64(c.Rect.X)), float64(c.Rect.X+c.Rect.W-1))
114+ y = min(max(y, float64(c.Rect.Y)), float64(c.Rect.Y+c.Rect.H-1))
115+ return x, y
116+}
117+
118+// PointerConstrained carries wayland → core the constraint active on a
119+// window (zwp_pointer_constraints_v1); ID 0 means none is active. A
120+// constraint is active only on the window with both pointer and keyboard
121+// focus.
122+type PointerConstrained struct {
123+ ID WindowID
124+ PointerConstraint
125+}
126+
127+func (PointerConstrained) clientEvent() {}
128+
86 // Mods carries input → core modifier flags.129 // Mods carries input → core modifier flags.
87 type Mods uint8130 type Mods uint8
88 131
@@ -117,9 +160,15 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 }
117 func (KeyEvent) inputEvent() {}160 func (KeyEvent) inputEvent() {}
118 161
119 // PointerMotion uses global layout coordinates in logical pixels (see OutputPlacement).162 // PointerMotion uses global layout coordinates in logical pixels (see OutputPlacement).
163+// DX and DY are the accelerated logical deltas and UnaccelDX, UnaccelDY the
164+// raw device deltas; all four are 0 for absolute devices. TimeUsec is the
165+// device timestamp in microseconds.
120 type PointerMotion struct {166 type PointerMotion struct {
121- X, Y float64167+ X, Y float64
122- TimeMsec uint32168+ DX, DY float64
169+ UnaccelDX, UnaccelDY float64
170+ TimeMsec uint32
171+ TimeUsec uint64
123 }172 }
124 173
125 func (PointerMotion) inputEvent() {}174 func (PointerMotion) inputEvent() {}
@@ -263,10 +312,16 @@ type PointerFocus struct {
263 312
264 func (PointerFocus) clientCommand() {}313 func (PointerFocus) clientCommand() {}
265 314
315+// PointerMotionTo carries core → wayland motion for the pointer focus, in
316+// window coordinates. The deltas and TimeUsec feed zwp_relative_pointer_v1;
317+// While the window locks the pointer, wayland sends only relative motion.
266 type PointerMotionTo struct {318 type PointerMotionTo struct {
267- ID WindowID319+ ID WindowID
268- X, Y float64320+ X, Y float64
269- TimeMsec uint32321+ DX, DY float64
322+ UnaccelDX, UnaccelDY float64
323+ TimeMsec uint32
324+ TimeUsec uint64
270 }325 }
271 326
272 func (PointerMotionTo) clientCommand() {}327 func (PointerMotionTo) 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 23

Tags 10

↑↓ moveenter openesc close