detached af33a35

feat(input): relative pointer and pointer lock/confine

commit
af33a3533fb8c45345dcaa63ed167c9eb6b45acc
author
brice · · 2026-09-25 12:37 UTC
parent
188b9af
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +754 -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 +37 -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,10 +265,12 @@ 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)
@@ -310,9 +324,10 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
310324 // is divided by the scale of the output under the pointer, so it moves the
311325 // same number of physical pixels on every output.
312326 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
327+ x, y float64
328+ layout ports.Layout
329+ touched bool // false until the user moves it: it follows the primary output
330+ constraint ports.PointerConstraint
316331 }
317332
318333 // newPointer starts at the centre of the primary output, else the first.
@@ -347,17 +362,29 @@ func (p *pointer) scale() float64 {
347362 return 1
348363 }
349364
350-func (p *pointer) move(dx, dy float64) (float64, float64) {
365+// move applies a relative motion and returns it with the logical deltas.
366+// A locked pointer stays still; a confined one stays in its rectangle.
367+func (p *pointer) move(dx, dy float64) ports.PointerMotion {
351368 s := p.scale()
352- return p.set(p.x+dx/s, p.y+dy/s)
369+ dx, dy = dx/s, dy/s
370+ if p.constraint.Mode != ports.ConstraintLock {
371+ p.set(p.x+dx, p.y+dy)
372+ }
373+ return ports.PointerMotion{X: p.x, Y: p.y, DX: dx, DY: dy}
353374 }
354375
355376 func (p *pointer) set(x, y float64) (float64, float64) {
356377 p.touched = true
357- p.x, p.y = p.layout.Clamp(p.x, p.y, x, y)
378+ p.x, p.y = p.constraint.Clamp(p.layout.Clamp(p.x, p.y, x, y))
358379 return p.x, p.y
359380 }
360381
382+// constrain applies a new constraint; a confined pointer moves inside.
383+func (p *pointer) constrain(c ports.PointerConstraint) {
384+ p.constraint = c
385+ p.x, p.y = c.Clamp(p.x, p.y)
386+}
387+
361388 // bounds is the rectangle holding every output.
362389 func (p *pointer) bounds() ports.Rect {
363390 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,10 +265,12 @@ 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)
@@ -310,9 +324,10 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
310 // is divided by the scale of the output under the pointer, so it moves the324 // is divided by the scale of the output under the pointer, so it moves the
311 // same number of physical pixels on every output.325 // same number of physical pixels on every output.
312 type pointer struct {326 type pointer struct {
313- x, y float64327+ x, y float64
314- layout ports.Layout328+ layout ports.Layout
315- touched bool // false until the user moves it: it follows the primary output329+ touched bool // false until the user moves it: it follows the primary output
330+ constraint ports.PointerConstraint
316 }331 }
317 332
318 // newPointer starts at the centre of the primary output, else the first.333 // newPointer starts at the centre of the primary output, else the first.
@@ -347,17 +362,29 @@ func (p *pointer) scale() float64 {
347 return 1362 return 1
348 }363 }
349 364
350-func (p *pointer) move(dx, dy float64) (float64, float64) {365+// move applies a relative motion and returns it with the logical deltas.
366+// A locked pointer stays still; a confined one stays in its rectangle.
367+func (p *pointer) move(dx, dy float64) ports.PointerMotion {
351 s := p.scale()368 s := p.scale()
352- return p.set(p.x+dx/s, p.y+dy/s)369+ dx, dy = dx/s, dy/s
370+ if p.constraint.Mode != ports.ConstraintLock {
371+ p.set(p.x+dx, p.y+dy)
372+ }
373+ return ports.PointerMotion{X: p.x, Y: p.y, DX: dx, DY: dy}
353 }374 }
354 375
355 func (p *pointer) set(x, y float64) (float64, float64) {376 func (p *pointer) set(x, y float64) (float64, float64) {
356 p.touched = true377 p.touched = true
357- p.x, p.y = p.layout.Clamp(p.x, p.y, x, y)378+ p.x, p.y = p.constraint.Clamp(p.layout.Clamp(p.x, p.y, x, y))
358 return p.x, p.y379 return p.x, p.y
359 }380 }
360 381
382+// constrain applies a new constraint; a confined pointer moves inside.
383+func (p *pointer) constrain(c ports.PointerConstraint) {
384+ p.constraint = c
385+ p.x, p.y = c.Clamp(p.x, p.y)
386+}
387+
361 // bounds is the rectangle holding every output.388 // bounds is the rectangle holding every output.
362 func (p *pointer) bounds() ports.Rect {389 func (p *pointer) bounds() ports.Rect {
363 var r ports.Rect390 var r ports.Rect
M internal/adapters/libinput/libinput_test.go +24 -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,23 @@ 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})
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}})
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+}
@@ -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,23 @@ 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})
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}})
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 internal/adapters/wayland/constraints.go +246 -0
@@ -0,0 +1,246 @@
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+func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) {
48+ if c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
49+ return
50+ }
51+ if !w.mapped || !w.xdg.resource.Resource.Alive() {
52+ return
53+ }
54+ for _, rel := range s.relatives[w.xdg.resource.Client()] {
55+ if rel.Resource.Alive() {
56+ 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))
57+ }
58+ }
59+}
60+
61+// constraint is a zwp_locked_pointer_v1 or zwp_confined_pointer_v1 on a
62+// surface. Its region is surface-local; empty means the whole surface.
63+type constraint struct {
64+ surface *surface
65+ lock *pointerconstraints.ZwpLockedPointerV1
66+ confine *pointerconstraints.ZwpConfinedPointerV1
67+ region ports.Rect
68+ pending *ports.Rect // set_region waiting for the surface commit
69+ persistent bool
70+ active bool
71+ defunct bool // a oneshot constraint that was deactivated
72+}
73+
74+type constraintManager struct{ server *Server }
75+
76+func (constraintManager) Destroy(*pointerconstraints.ZwpPointerConstraintsV1) {}
77+func (m constraintManager) LockPointer(r *pointerconstraints.ZwpPointerConstraintsV1, id uint32, surf *wayland.Surface, _ *wayland.Pointer, reg *wayland.Region, lifetime uint32) {
78+ c := m.create(r, surf, reg, lifetime)
79+ if c == nil {
80+ return
81+ }
82+ lock, err := pointerconstraints.NewZwpLockedPointerV1(r.Client(), r.Version(), id, lockHandler{c})
83+ if err != nil {
84+ delete(m.server.constraints, c.surface)
85+ return
86+ }
87+ c.lock = lock
88+ lock.OnDestroy = func() { m.server.dropConstraint(c) }
89+ m.server.updateConstraint()
90+}
91+func (m constraintManager) ConfinePointer(r *pointerconstraints.ZwpPointerConstraintsV1, id uint32, surf *wayland.Surface, _ *wayland.Pointer, reg *wayland.Region, lifetime uint32) {
92+ c := m.create(r, surf, reg, lifetime)
93+ if c == nil {
94+ return
95+ }
96+ confine, err := pointerconstraints.NewZwpConfinedPointerV1(r.Client(), r.Version(), id, confineHandler{c})
97+ if err != nil {
98+ delete(m.server.constraints, c.surface)
99+ return
100+ }
101+ c.confine = confine
102+ confine.OnDestroy = func() { m.server.dropConstraint(c) }
103+ m.server.updateConstraint()
104+}
105+
106+// create validates the request and registers the constraint on the surface.
107+func (m constraintManager) create(r *pointerconstraints.ZwpPointerConstraintsV1, surf *wayland.Surface, reg *wayland.Region, lifetime uint32) *constraint {
108+ s := m.server
109+ if surf == nil {
110+ return nil
111+ }
112+ state := s.surfaces[surf.Resource]
113+ if state == nil {
114+ return nil
115+ }
116+ if s.constraints[state] != nil {
117+ r.PostError(uint32(pointerconstraints.ZwpPointerConstraintsV1ErrorAlreadyConstrained), "surface already constrained")
118+ return nil
119+ }
120+ c := &constraint{surface: state, region: s.regionBox(reg), persistent: lifetime == uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent)}
121+ s.constraints[state] = c
122+ return c
123+}
124+
125+// regionBox copies a region: the protocol reads it at request time.
126+func (s *Server) regionBox(reg *wayland.Region) ports.Rect {
127+ if reg == nil || reg.Resource == nil {
128+ return ports.Rect{}
129+ }
130+ if g := s.regions[reg.Resource]; g != nil {
131+ return g.box
132+ }
133+ return ports.Rect{}
134+}
135+
136+type lockHandler struct{ c *constraint }
137+
138+func (lockHandler) Destroy(*pointerconstraints.ZwpLockedPointerV1) {}
139+
140+// SetCursorPositionHint is ignored: the pointer stays where it locked.
141+func (lockHandler) SetCursorPositionHint(*pointerconstraints.ZwpLockedPointerV1, server.Fixed, server.Fixed) {
142+}
143+func (h lockHandler) SetRegion(_ *pointerconstraints.ZwpLockedPointerV1, reg *wayland.Region) {
144+ r := h.c.surface.server.regionBox(reg)
145+ h.c.pending = &r
146+}
147+
148+type confineHandler struct{ c *constraint }
149+
150+func (confineHandler) Destroy(*pointerconstraints.ZwpConfinedPointerV1) {}
151+func (h confineHandler) SetRegion(_ *pointerconstraints.ZwpConfinedPointerV1, reg *wayland.Region) {
152+ r := h.c.surface.server.regionBox(reg)
153+ h.c.pending = &r
154+}
155+
156+// commitConstraint applies a pending region on the surface commit.
157+func (s *surface) commitConstraint() {
158+ c := s.server.constraints[s]
159+ if c == nil || c.pending == nil {
160+ return
161+ }
162+ c.region, c.pending = *c.pending, nil
163+ if c.active {
164+ s.server.emitConstraint(c)
165+ }
166+}
167+
168+// dropConstraint forgets a destroyed constraint or one on a destroyed surface.
169+func (s *Server) dropConstraint(c *constraint) {
170+ if s.constraints[c.surface] == c {
171+ delete(s.constraints, c.surface)
172+ }
173+ if s.constraint == c {
174+ c.active = false
175+ s.constraint = nil
176+ if c.lock != nil && c.lock.Resource.Alive() {
177+ c.lock.SendUnlocked()
178+ }
179+ if c.confine != nil && c.confine.Resource.Alive() {
180+ c.confine.SendUnconfined()
181+ }
182+ s.emit(ports.PointerConstrained{})
183+ }
184+ s.updateConstraint()
185+}
186+
187+// updateConstraint activates the constraint of the window holding both
188+// pointer and keyboard focus, and deactivates any other.
189+func (s *Server) updateConstraint() {
190+ var want *constraint
191+ if w := s.windows[s.pointerFocus]; w != nil && w.mapped && s.pointerFocus == s.focused {
192+ if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct {
193+ want = c
194+ }
195+ }
196+ if want == s.constraint {
197+ return
198+ }
199+ if old := s.constraint; old != nil {
200+ old.active = false
201+ if !old.persistent {
202+ old.defunct = true
203+ }
204+ if old.lock != nil && old.lock.Resource.Alive() {
205+ old.lock.SendUnlocked()
206+ }
207+ if old.confine != nil && old.confine.Resource.Alive() {
208+ old.confine.SendUnconfined()
209+ }
210+ }
211+ s.constraint = want
212+ if want == nil {
213+ s.emit(ports.PointerConstrained{})
214+ return
215+ }
216+ want.active = true
217+ if want.lock != nil {
218+ want.lock.SendLocked()
219+ } else {
220+ want.confine.SendConfined()
221+ }
222+ s.emitConstraint(want)
223+}
224+
225+// emitConstraint tells core about the active constraint, window-local.
226+func (s *Server) emitConstraint(c *constraint) {
227+ x := c.surface.xdg
228+ if x == nil || x.window == nil {
229+ return
230+ }
231+ mode := ports.ConstraintConfine
232+ if c.lock != nil {
233+ mode = ports.ConstraintLock
234+ }
235+ r := c.region
236+ if r.W > 0 && r.H > 0 {
237+ r.X -= x.geometry.X
238+ r.Y -= x.geometry.Y
239+ }
240+ s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
241+}
242+
243+// locked reports whether the window's pointer is locked.
244+func (s *Server) locked(w *window) bool {
245+ return s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
246+}
@@ -0,0 +1,246 @@
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+func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) {
48+ if c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
49+ return
50+ }
51+ if !w.mapped || !w.xdg.resource.Resource.Alive() {
52+ return
53+ }
54+ for _, rel := range s.relatives[w.xdg.resource.Client()] {
55+ if rel.Resource.Alive() {
56+ 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))
57+ }
58+ }
59+}
60+
61+// constraint is a zwp_locked_pointer_v1 or zwp_confined_pointer_v1 on a
62+// surface. Its region is surface-local; empty means the whole surface.
63+type constraint struct {
64+ surface *surface
65+ lock *pointerconstraints.ZwpLockedPointerV1
66+ confine *pointerconstraints.ZwpConfinedPointerV1
67+ region ports.Rect
68+ pending *ports.Rect // set_region waiting for the surface commit
69+ persistent bool
70+ active bool
71+ defunct bool // a oneshot constraint that was deactivated
72+}
73+
74+type constraintManager struct{ server *Server }
75+
76+func (constraintManager) Destroy(*pointerconstraints.ZwpPointerConstraintsV1) {}
77+func (m constraintManager) LockPointer(r *pointerconstraints.ZwpPointerConstraintsV1, id uint32, surf *wayland.Surface, _ *wayland.Pointer, reg *wayland.Region, lifetime uint32) {
78+ c := m.create(r, surf, reg, lifetime)
79+ if c == nil {
80+ return
81+ }
82+ lock, err := pointerconstraints.NewZwpLockedPointerV1(r.Client(), r.Version(), id, lockHandler{c})
83+ if err != nil {
84+ delete(m.server.constraints, c.surface)
85+ return
86+ }
87+ c.lock = lock
88+ lock.OnDestroy = func() { m.server.dropConstraint(c) }
89+ m.server.updateConstraint()
90+}
91+func (m constraintManager) ConfinePointer(r *pointerconstraints.ZwpPointerConstraintsV1, id uint32, surf *wayland.Surface, _ *wayland.Pointer, reg *wayland.Region, lifetime uint32) {
92+ c := m.create(r, surf, reg, lifetime)
93+ if c == nil {
94+ return
95+ }
96+ confine, err := pointerconstraints.NewZwpConfinedPointerV1(r.Client(), r.Version(), id, confineHandler{c})
97+ if err != nil {
98+ delete(m.server.constraints, c.surface)
99+ return
100+ }
101+ c.confine = confine
102+ confine.OnDestroy = func() { m.server.dropConstraint(c) }
103+ m.server.updateConstraint()
104+}
105+
106+// create validates the request and registers the constraint on the surface.
107+func (m constraintManager) create(r *pointerconstraints.ZwpPointerConstraintsV1, surf *wayland.Surface, reg *wayland.Region, lifetime uint32) *constraint {
108+ s := m.server
109+ if surf == nil {
110+ return nil
111+ }
112+ state := s.surfaces[surf.Resource]
113+ if state == nil {
114+ return nil
115+ }
116+ if s.constraints[state] != nil {
117+ r.PostError(uint32(pointerconstraints.ZwpPointerConstraintsV1ErrorAlreadyConstrained), "surface already constrained")
118+ return nil
119+ }
120+ c := &constraint{surface: state, region: s.regionBox(reg), persistent: lifetime == uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent)}
121+ s.constraints[state] = c
122+ return c
123+}
124+
125+// regionBox copies a region: the protocol reads it at request time.
126+func (s *Server) regionBox(reg *wayland.Region) ports.Rect {
127+ if reg == nil || reg.Resource == nil {
128+ return ports.Rect{}
129+ }
130+ if g := s.regions[reg.Resource]; g != nil {
131+ return g.box
132+ }
133+ return ports.Rect{}
134+}
135+
136+type lockHandler struct{ c *constraint }
137+
138+func (lockHandler) Destroy(*pointerconstraints.ZwpLockedPointerV1) {}
139+
140+// SetCursorPositionHint is ignored: the pointer stays where it locked.
141+func (lockHandler) SetCursorPositionHint(*pointerconstraints.ZwpLockedPointerV1, server.Fixed, server.Fixed) {
142+}
143+func (h lockHandler) SetRegion(_ *pointerconstraints.ZwpLockedPointerV1, reg *wayland.Region) {
144+ r := h.c.surface.server.regionBox(reg)
145+ h.c.pending = &r
146+}
147+
148+type confineHandler struct{ c *constraint }
149+
150+func (confineHandler) Destroy(*pointerconstraints.ZwpConfinedPointerV1) {}
151+func (h confineHandler) SetRegion(_ *pointerconstraints.ZwpConfinedPointerV1, reg *wayland.Region) {
152+ r := h.c.surface.server.regionBox(reg)
153+ h.c.pending = &r
154+}
155+
156+// commitConstraint applies a pending region on the surface commit.
157+func (s *surface) commitConstraint() {
158+ c := s.server.constraints[s]
159+ if c == nil || c.pending == nil {
160+ return
161+ }
162+ c.region, c.pending = *c.pending, nil
163+ if c.active {
164+ s.server.emitConstraint(c)
165+ }
166+}
167+
168+// dropConstraint forgets a destroyed constraint or one on a destroyed surface.
169+func (s *Server) dropConstraint(c *constraint) {
170+ if s.constraints[c.surface] == c {
171+ delete(s.constraints, c.surface)
172+ }
173+ if s.constraint == c {
174+ c.active = false
175+ s.constraint = nil
176+ if c.lock != nil && c.lock.Resource.Alive() {
177+ c.lock.SendUnlocked()
178+ }
179+ if c.confine != nil && c.confine.Resource.Alive() {
180+ c.confine.SendUnconfined()
181+ }
182+ s.emit(ports.PointerConstrained{})
183+ }
184+ s.updateConstraint()
185+}
186+
187+// updateConstraint activates the constraint of the window holding both
188+// pointer and keyboard focus, and deactivates any other.
189+func (s *Server) updateConstraint() {
190+ var want *constraint
191+ if w := s.windows[s.pointerFocus]; w != nil && w.mapped && s.pointerFocus == s.focused {
192+ if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct {
193+ want = c
194+ }
195+ }
196+ if want == s.constraint {
197+ return
198+ }
199+ if old := s.constraint; old != nil {
200+ old.active = false
201+ if !old.persistent {
202+ old.defunct = true
203+ }
204+ if old.lock != nil && old.lock.Resource.Alive() {
205+ old.lock.SendUnlocked()
206+ }
207+ if old.confine != nil && old.confine.Resource.Alive() {
208+ old.confine.SendUnconfined()
209+ }
210+ }
211+ s.constraint = want
212+ if want == nil {
213+ s.emit(ports.PointerConstrained{})
214+ return
215+ }
216+ want.active = true
217+ if want.lock != nil {
218+ want.lock.SendLocked()
219+ } else {
220+ want.confine.SendConfined()
221+ }
222+ s.emitConstraint(want)
223+}
224+
225+// emitConstraint tells core about the active constraint, window-local.
226+func (s *Server) emitConstraint(c *constraint) {
227+ x := c.surface.xdg
228+ if x == nil || x.window == nil {
229+ return
230+ }
231+ mode := ports.ConstraintConfine
232+ if c.lock != nil {
233+ mode = ports.ConstraintLock
234+ }
235+ r := c.region
236+ if r.W > 0 && r.H > 0 {
237+ r.X -= x.geometry.X
238+ r.Y -= x.geometry.Y
239+ }
240+ s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
241+}
242+
243+// locked reports whether the window's pointer is locked.
244+func (s *Server) locked(w *window) bool {
245+ return s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
246+}
A internal/adapters/wayland/constraints_test.go +191 -0
@@ -0,0 +1,191 @@
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/wlturbo"
12+)
13+
14+// eventLog records the opcodes (and first fixed args) a proxy receives.
15+type eventLog struct {
16+ wlturbo.BaseProxy
17+ events chan []float64 // opcode, then fixed args for relative motion
18+}
19+
20+func (p *eventLog) Dispatch(e *wlturbo.Event) {
21+ ev := []float64{float64(e.Opcode)}
22+ if e.Opcode == uint16(relativepointer.ZwpRelativePointerV1EventRelativeMotion) && e.ProxyID == p.ID() {
23+ e.Uint32()
24+ e.Uint32()
25+ for range 4 {
26+ ev = append(ev, e.Fixed().Float64())
27+ }
28+ }
29+ p.events <- ev
30+}
31+
32+func logProxy(t *testing.T, c *wlturbo.Display, id uint32) chan []float64 {
33+ t.Helper()
34+ p := &eventLog{events: make(chan []float64, 64)}
35+ p.SetID(id)
36+ c.Context().Register(p)
37+ return p.events
38+}
39+
40+// next dispatches until the proxy receives an event.
41+func next(t *testing.T, c *wlturbo.Display, events chan []float64) []float64 {
42+ t.Helper()
43+ deadline := time.Now().Add(2 * time.Second)
44+ for time.Now().Before(deadline) {
45+ select {
46+ case ev := <-events:
47+ return ev
48+ default:
49+ }
50+ if err := c.Roundtrip(); err != nil {
51+ t.Fatal(err)
52+ }
53+ }
54+ t.Fatal("no event")
55+ return nil
56+}
57+
58+func constrained(t *testing.T, events <-chan ports.ClientEvent) ports.PointerConstrained {
59+ t.Helper()
60+ for {
61+ if v, ok := waitEvent(t, events, 2*time.Second).(ports.PointerConstrained); ok {
62+ return v
63+ }
64+ }
65+}
66+
67+func TestPointerLockAndRelativeMotion(t *testing.T) {
68+ s, events, commands, dir := lifecycleServer(t)
69+ c := protocolClient(t, s, dir)
70+ seat := bindProtocol(t, c, "wl_seat")
71+ registerProtocol(t, c, seat)
72+ pointer := c.AllocateID()
73+ pointerEvents := logProxy(t, c, pointer)
74+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
75+ relManager := bindProtocol(t, c, "zwp_relative_pointer_manager_v1")
76+ rel := c.AllocateID()
77+ relEvents := logProxy(t, c, rel)
78+ requestProtocol(t, c, relManager, relativepointer.ZwpRelativePointerManagerV1RequestGetRelativePointer, rel, pointer)
79+ w, surf := surfaceMapper(t, c, events)()
80+
81+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
82+ lock := c.AllocateID()
83+ lockEvents := logProxy(t, c, lock)
84+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
85+ if err := c.Roundtrip(); err != nil {
86+ t.Fatal(err)
87+ }
88+ select {
89+ case ev := <-lockEvents:
90+ t.Fatalf("locked without focus: %v", ev)
91+ default:
92+ }
93+
94+ // Pointer and keyboard focus activate the lock.
95+ commands <- ports.PointerFocus{ID: w.ID}
96+ commands <- ports.FocusWindow{ID: w.ID}
97+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
98+ t.Fatalf("lock event %v", ev)
99+ }
100+ if got := constrained(t, events); got.ID != w.ID || got.Mode != ports.ConstraintLock {
101+ t.Fatalf("constrained %+v", got)
102+ }
103+ for len(pointerEvents) > 0 {
104+ <-pointerEvents
105+ }
106+
107+ // Locked: relative motion flows, absolute motion does not.
108+ commands <- ports.PointerMotionTo{ID: w.ID, X: 5, Y: 5, DX: 1.5, DY: -2, UnaccelDX: 3, UnaccelDY: -4}
109+ if ev := next(t, c, relEvents); len(ev) != 5 || ev[1] != 1.5 || ev[2] != -2 || ev[3] != 3 || ev[4] != -4 {
110+ t.Fatalf("relative motion %v", ev)
111+ }
112+ for len(pointerEvents) > 0 {
113+ if ev := <-pointerEvents; ev[0] == float64(wayland.PointerEventMotion) {
114+ t.Fatal("motion sent to a locked pointer")
115+ }
116+ }
117+
118+ // Losing focus unlocks; a persistent lock comes back.
119+ commands <- ports.FocusWindow{}
120+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventUnlocked) {
121+ t.Fatalf("unlock event %v", ev)
122+ }
123+ if got := constrained(t, events); got.ID != 0 {
124+ t.Fatalf("constrained %+v", got)
125+ }
126+ commands <- ports.FocusWindow{ID: w.ID}
127+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
128+ t.Fatalf("relock event %v", ev)
129+ }
130+}
131+
132+func TestPointerConfineOneshotRegion(t *testing.T) {
133+ s, events, commands, dir := lifecycleServer(t)
134+ c := protocolClient(t, s, dir)
135+ seat := bindProtocol(t, c, "wl_seat")
136+ registerProtocol(t, c, seat)
137+ pointer := c.AllocateID()
138+ registerProtocol(t, c, pointer)
139+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
140+ comp := bindProtocol(t, c, "wl_compositor")
141+ w, surf := surfaceMapper(t, c, events)()
142+ region := c.AllocateID()
143+ registerProtocol(t, c, region)
144+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
145+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(2), int32(3), int32(10), int32(10))
146+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(20), int32(3), int32(5), int32(20))
147+
148+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
149+ confine := c.AllocateID()
150+ confineEvents := logProxy(t, c, confine)
151+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestConfinePointer, confine, surf, pointer, region, uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot))
152+ // The region is copied: changing it later has no effect.
153+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(0), int32(0), int32(100), int32(100))
154+ commands <- ports.FocusWindow{ID: w.ID}
155+ commands <- ports.PointerFocus{ID: w.ID}
156+ if ev := next(t, c, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventConfined) {
157+ t.Fatalf("confine event %v", ev)
158+ }
159+ want := ports.PointerConstrained{ID: w.ID, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 2, Y: 3, W: 23, H: 20}}}
160+ if got := constrained(t, events); got != want {
161+ t.Fatalf("constrained %+v, want %+v", got, want)
162+ }
163+
164+ // A new region applies on commit.
165+ requestProtocol(t, c, confine, pointerconstraints.ZwpConfinedPointerV1RequestSetRegion, uint32(0))
166+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
167+ if got := constrained(t, events); got.Rect != (ports.Rect{}) {
168+ t.Fatalf("whole surface %+v", got)
169+ }
170+
171+ // A oneshot constraint ends for good with the focus.
172+ commands <- ports.PointerFocus{}
173+ if ev := next(t, c, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventUnconfined) {
174+ t.Fatalf("unconfine event %v", ev)
175+ }
176+ commands <- ports.PointerFocus{ID: w.ID}
177+ if err := c.Roundtrip(); err != nil {
178+ t.Fatal(err)
179+ }
180+ select {
181+ case ev := <-confineEvents:
182+ t.Fatalf("oneshot reactivated: %v", ev)
183+ case <-time.After(50 * time.Millisecond):
184+ }
185+
186+ // A second constraint on the surface is a protocol error.
187+ again := c.AllocateID()
188+ registerProtocol(t, c, again)
189+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, again, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot))
190+ expectProtocolError(t, c, constraints, uint32(pointerconstraints.ZwpPointerConstraintsV1ErrorAlreadyConstrained))
191+}
@@ -0,0 +1,191 @@
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/wlturbo"
12+)
13+
14+// eventLog records the opcodes (and first fixed args) a proxy receives.
15+type eventLog struct {
16+ wlturbo.BaseProxy
17+ events chan []float64 // opcode, then fixed args for relative motion
18+}
19+
20+func (p *eventLog) Dispatch(e *wlturbo.Event) {
21+ ev := []float64{float64(e.Opcode)}
22+ if e.Opcode == uint16(relativepointer.ZwpRelativePointerV1EventRelativeMotion) && e.ProxyID == p.ID() {
23+ e.Uint32()
24+ e.Uint32()
25+ for range 4 {
26+ ev = append(ev, e.Fixed().Float64())
27+ }
28+ }
29+ p.events <- ev
30+}
31+
32+func logProxy(t *testing.T, c *wlturbo.Display, id uint32) chan []float64 {
33+ t.Helper()
34+ p := &eventLog{events: make(chan []float64, 64)}
35+ p.SetID(id)
36+ c.Context().Register(p)
37+ return p.events
38+}
39+
40+// next dispatches until the proxy receives an event.
41+func next(t *testing.T, c *wlturbo.Display, events chan []float64) []float64 {
42+ t.Helper()
43+ deadline := time.Now().Add(2 * time.Second)
44+ for time.Now().Before(deadline) {
45+ select {
46+ case ev := <-events:
47+ return ev
48+ default:
49+ }
50+ if err := c.Roundtrip(); err != nil {
51+ t.Fatal(err)
52+ }
53+ }
54+ t.Fatal("no event")
55+ return nil
56+}
57+
58+func constrained(t *testing.T, events <-chan ports.ClientEvent) ports.PointerConstrained {
59+ t.Helper()
60+ for {
61+ if v, ok := waitEvent(t, events, 2*time.Second).(ports.PointerConstrained); ok {
62+ return v
63+ }
64+ }
65+}
66+
67+func TestPointerLockAndRelativeMotion(t *testing.T) {
68+ s, events, commands, dir := lifecycleServer(t)
69+ c := protocolClient(t, s, dir)
70+ seat := bindProtocol(t, c, "wl_seat")
71+ registerProtocol(t, c, seat)
72+ pointer := c.AllocateID()
73+ pointerEvents := logProxy(t, c, pointer)
74+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
75+ relManager := bindProtocol(t, c, "zwp_relative_pointer_manager_v1")
76+ rel := c.AllocateID()
77+ relEvents := logProxy(t, c, rel)
78+ requestProtocol(t, c, relManager, relativepointer.ZwpRelativePointerManagerV1RequestGetRelativePointer, rel, pointer)
79+ w, surf := surfaceMapper(t, c, events)()
80+
81+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
82+ lock := c.AllocateID()
83+ lockEvents := logProxy(t, c, lock)
84+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
85+ if err := c.Roundtrip(); err != nil {
86+ t.Fatal(err)
87+ }
88+ select {
89+ case ev := <-lockEvents:
90+ t.Fatalf("locked without focus: %v", ev)
91+ default:
92+ }
93+
94+ // Pointer and keyboard focus activate the lock.
95+ commands <- ports.PointerFocus{ID: w.ID}
96+ commands <- ports.FocusWindow{ID: w.ID}
97+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
98+ t.Fatalf("lock event %v", ev)
99+ }
100+ if got := constrained(t, events); got.ID != w.ID || got.Mode != ports.ConstraintLock {
101+ t.Fatalf("constrained %+v", got)
102+ }
103+ for len(pointerEvents) > 0 {
104+ <-pointerEvents
105+ }
106+
107+ // Locked: relative motion flows, absolute motion does not.
108+ commands <- ports.PointerMotionTo{ID: w.ID, X: 5, Y: 5, DX: 1.5, DY: -2, UnaccelDX: 3, UnaccelDY: -4}
109+ if ev := next(t, c, relEvents); len(ev) != 5 || ev[1] != 1.5 || ev[2] != -2 || ev[3] != 3 || ev[4] != -4 {
110+ t.Fatalf("relative motion %v", ev)
111+ }
112+ for len(pointerEvents) > 0 {
113+ if ev := <-pointerEvents; ev[0] == float64(wayland.PointerEventMotion) {
114+ t.Fatal("motion sent to a locked pointer")
115+ }
116+ }
117+
118+ // Losing focus unlocks; a persistent lock comes back.
119+ commands <- ports.FocusWindow{}
120+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventUnlocked) {
121+ t.Fatalf("unlock event %v", ev)
122+ }
123+ if got := constrained(t, events); got.ID != 0 {
124+ t.Fatalf("constrained %+v", got)
125+ }
126+ commands <- ports.FocusWindow{ID: w.ID}
127+ if ev := next(t, c, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
128+ t.Fatalf("relock event %v", ev)
129+ }
130+}
131+
132+func TestPointerConfineOneshotRegion(t *testing.T) {
133+ s, events, commands, dir := lifecycleServer(t)
134+ c := protocolClient(t, s, dir)
135+ seat := bindProtocol(t, c, "wl_seat")
136+ registerProtocol(t, c, seat)
137+ pointer := c.AllocateID()
138+ registerProtocol(t, c, pointer)
139+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
140+ comp := bindProtocol(t, c, "wl_compositor")
141+ w, surf := surfaceMapper(t, c, events)()
142+ region := c.AllocateID()
143+ registerProtocol(t, c, region)
144+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
145+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(2), int32(3), int32(10), int32(10))
146+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(20), int32(3), int32(5), int32(20))
147+
148+ constraints := bindProtocol(t, c, "zwp_pointer_constraints_v1")
149+ confine := c.AllocateID()
150+ confineEvents := logProxy(t, c, confine)
151+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestConfinePointer, confine, surf, pointer, region, uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot))
152+ // The region is copied: changing it later has no effect.
153+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(0), int32(0), int32(100), int32(100))
154+ commands <- ports.FocusWindow{ID: w.ID}
155+ commands <- ports.PointerFocus{ID: w.ID}
156+ if ev := next(t, c, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventConfined) {
157+ t.Fatalf("confine event %v", ev)
158+ }
159+ want := ports.PointerConstrained{ID: w.ID, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 2, Y: 3, W: 23, H: 20}}}
160+ if got := constrained(t, events); got != want {
161+ t.Fatalf("constrained %+v, want %+v", got, want)
162+ }
163+
164+ // A new region applies on commit.
165+ requestProtocol(t, c, confine, pointerconstraints.ZwpConfinedPointerV1RequestSetRegion, uint32(0))
166+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
167+ if got := constrained(t, events); got.Rect != (ports.Rect{}) {
168+ t.Fatalf("whole surface %+v", got)
169+ }
170+
171+ // A oneshot constraint ends for good with the focus.
172+ commands <- ports.PointerFocus{}
173+ if ev := next(t, c, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventUnconfined) {
174+ t.Fatalf("unconfine event %v", ev)
175+ }
176+ commands <- ports.PointerFocus{ID: w.ID}
177+ if err := c.Roundtrip(); err != nil {
178+ t.Fatal(err)
179+ }
180+ select {
181+ case ev := <-confineEvents:
182+ t.Fatalf("oneshot reactivated: %v", ev)
183+ case <-time.After(50 * time.Millisecond):
184+ }
185+
186+ // A second constraint on the surface is a protocol error.
187+ again := c.AllocateID()
188+ registerProtocol(t, c, again)
189+ requestProtocol(t, c, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, again, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot))
190+ expectProtocolError(t, c, constraints, uint32(pointerconstraints.ZwpPointerConstraintsV1ErrorAlreadyConstrained))
191+}
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 +9 -2
@@ -590,6 +590,13 @@ 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 ID.
599+func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() (ports.WindowMapped, uint32) {
593600 t.Helper()
594601 comp := bindProtocol(t, c, "wl_compositor")
595602 wm := bindProtocol(t, c, "xdg_wm_base")
@@ -610,7 +617,7 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client
610617 t.Fatal(err)
611618 }
612619 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
613- return func() ports.WindowMapped {
620+ return func() (ports.WindowMapped, uint32) {
614621 surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
615622 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
616623 registerProtocol(t, c, surf)
@@ -636,6 +643,6 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client
636643 if err := c.Roundtrip(); err != nil {
637644 t.Fatal(err)
638645 }
639- return mapped(t, events, 2*time.Second)
646+ return mapped(t, events, 2*time.Second), surf
640647 }
641648 }
@@ -590,6 +590,13 @@ 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 ID.
599+func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() (ports.WindowMapped, uint32) {
593 t.Helper()600 t.Helper()
594 comp := bindProtocol(t, c, "wl_compositor")601 comp := bindProtocol(t, c, "wl_compositor")
595 wm := bindProtocol(t, c, "xdg_wm_base")602 wm := bindProtocol(t, c, "xdg_wm_base")
@@ -610,7 +617,7 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client
610 t.Fatal(err)617 t.Fatal(err)
611 }618 }
612 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))619 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
613- return func() ports.WindowMapped {620+ return func() (ports.WindowMapped, uint32) {
614 surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()621 surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
615 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)622 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
616 registerProtocol(t, c, surf)623 registerProtocol(t, c, surf)
@@ -636,6 +643,6 @@ func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.Client
636 if err := c.Roundtrip(); err != nil {643 if err := c.Roundtrip(); err != nil {
637 t.Fatal(err)644 t.Fatal(err)
638 }645 }
639- return mapped(t, events, 2*time.Second)646+ return mapped(t, events, 2*time.Second), surf
640 }647 }
641 }648 }
M internal/adapters/wayland/server.go +18 -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,16 @@ 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+ s.relativeMotion(w, c)
371+ if s.locked(w) {
372+ // Only relative motion reaches a locked pointer.
373+ for _, p := range s.windowPointers(w) {
374+ pointerFrame(p)
375+ }
376+ return
377+ }
364378 x, y := w.surfacePoint(c.X, c.Y)
379+ s.pointerX, s.pointerY = c.X, c.Y
365380 for _, p := range s.windowPointers(w) {
366381 p.SendMotion(c.TimeMsec, server.FixedFromFloat(x), server.FixedFromFloat(y))
367382 pointerFrame(p)
@@ -566,6 +581,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
566581 }
567582 }
568583 s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")
584+ s.updateConstraint()
569585 }
570586
571587 func pointerFrame(p *wayland.Pointer) {
@@ -613,4 +629,5 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
613629 }
614630 }
615631 }
632+ s.updateConstraint()
616633 }
@@ -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,16 @@ 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+ s.relativeMotion(w, c)
371+ if s.locked(w) {
372+ // Only relative motion reaches a locked pointer.
373+ for _, p := range s.windowPointers(w) {
374+ pointerFrame(p)
375+ }
376+ return
377+ }
364 x, y := w.surfacePoint(c.X, c.Y)378 x, y := w.surfacePoint(c.X, c.Y)
379+ s.pointerX, s.pointerY = c.X, c.Y
365 for _, p := range s.windowPointers(w) {380 for _, p := range s.windowPointers(w) {
366 p.SendMotion(c.TimeMsec, server.FixedFromFloat(x), server.FixedFromFloat(y))381 p.SendMotion(c.TimeMsec, server.FixedFromFloat(x), server.FixedFromFloat(y))
367 pointerFrame(p)382 pointerFrame(p)
@@ -566,6 +581,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
566 }581 }
567 }582 }
568 s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")583 s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")
584+ s.updateConstraint()
569 }585 }
570 586
571 func pointerFrame(p *wayland.Pointer) {587 func pointerFrame(p *wayland.Pointer) {
@@ -613,4 +629,5 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
613 }629 }
614 }630 }
615 }631 }
632+ s.updateConstraint()
616 }633 }
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
@@ -194,6 +197,7 @@ func (s *surface) Commit(*wayland.Surface) {
194197 s.pending = nil
195198 s.attached = false
196199 }
200+ s.commitConstraint()
197201 s.server.awaiting = append(s.server.awaiting, s.callbacks...)
198202 s.callbacks = nil
199203 if s.role != nil {
@@ -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
@@ -194,6 +197,7 @@ func (s *surface) Commit(*wayland.Surface) {
194 s.pending = nil197 s.pending = nil
195 s.attached = false198 s.attached = false
196 }199 }
200+ s.commitConstraint()
197 s.server.awaiting = append(s.server.awaiting, s.callbacks...)201 s.server.awaiting = append(s.server.awaiting, s.callbacks...)
198 s.callbacks = nil202 s.callbacks = nil
199 if s.role != nil {203 if s.role != nil {
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 +54 -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,46 @@ 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+ if g == c.constraint {
448+ return
449+ }
450+ c.constraint = g
451+ c.cursorX, c.cursorY = g.Clamp(c.cursorX, c.cursorY)
452+ if c.ch.Constraints != nil {
453+ latest(c.ch.Constraints, g)
454+ }
455+}
456+
414457 // latest replaces any unread value: only the owner sends and drains;
415458 // consumers only receive.
416459 func latest[T any](ch chan T, v T) {
@@ -517,6 +560,8 @@ func (c *Core) Run(ctx context.Context) error {
517560 return nil
518561 }
519562 }
563+ case ports.PointerConstrained:
564+ c.constrained = v
520565 case ports.WindowFullscreenRequest:
521566 if s, _ := c.screenOf(v.ID); s != nil {
522567 s.mon.SetFullscreen(v.ID, v.Fullscreen)
@@ -571,7 +616,10 @@ func (c *Core) Run(ctx context.Context) error {
571616 }
572617 switch v := ev.(type) {
573618 case ports.PointerMotion:
574- c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)
619+ // A locked pointer stays still; relative motion still flows.
620+ if c.constraint.Mode != ports.ConstraintLock {
621+ c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y))
622+ }
575623 // The focused screen follows the pointer, so new windows
576624 // and launchers open where the user is.
577625 // Only a pointer entering another output switches: keyboard
@@ -594,7 +642,7 @@ func (c *Core) Run(ctx context.Context) error {
594642 }
595643 }
596644 if id != 0 {
597- if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: v.TimeMsec}); err != nil {
645+ 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 {
598646 return nil
599647 }
600648 }
@@ -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,46 @@ 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+ if g == c.constraint {
448+ return
449+ }
450+ c.constraint = g
451+ c.cursorX, c.cursorY = g.Clamp(c.cursorX, c.cursorY)
452+ if c.ch.Constraints != nil {
453+ latest(c.ch.Constraints, g)
454+ }
455+}
456+
414 // latest replaces any unread value: only the owner sends and drains;457 // latest replaces any unread value: only the owner sends and drains;
415 // consumers only receive.458 // consumers only receive.
416 func latest[T any](ch chan T, v T) {459 func latest[T any](ch chan T, v T) {
@@ -517,6 +560,8 @@ func (c *Core) Run(ctx context.Context) error {
517 return nil560 return nil
518 }561 }
519 }562 }
563+ case ports.PointerConstrained:
564+ c.constrained = v
520 case ports.WindowFullscreenRequest:565 case ports.WindowFullscreenRequest:
521 if s, _ := c.screenOf(v.ID); s != nil {566 if s, _ := c.screenOf(v.ID); s != nil {
522 s.mon.SetFullscreen(v.ID, v.Fullscreen)567 s.mon.SetFullscreen(v.ID, v.Fullscreen)
@@ -571,7 +616,10 @@ func (c *Core) Run(ctx context.Context) error {
571 }616 }
572 switch v := ev.(type) {617 switch v := ev.(type) {
573 case ports.PointerMotion:618 case ports.PointerMotion:
574- c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)619+ // A locked pointer stays still; relative motion still flows.
620+ if c.constraint.Mode != ports.ConstraintLock {
621+ c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y))
622+ }
575 // The focused screen follows the pointer, so new windows623 // The focused screen follows the pointer, so new windows
576 // and launchers open where the user is.624 // and launchers open where the user is.
577 // Only a pointer entering another output switches: keyboard625 // Only a pointer entering another output switches: keyboard
@@ -594,7 +642,7 @@ func (c *Core) Run(ctx context.Context) error {
594 }642 }
595 }643 }
596 if id != 0 {644 if id != 0 {
597- if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: v.TimeMsec}); err != nil {645+ 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 nil646 return nil
599 }647 }
600 }648 }
M internal/core/core_test.go +70 -0
@@ -570,3 +570,73 @@ 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+ if got := receive(t, constraints); got != want {
604+ t.Fatalf("got %+v, want %+v", got, want)
605+ }
606+ for len(commands) > 0 {
607+ <-commands
608+ }
609+ // Motion is clamped into the region; deltas pass through.
610+ input <- ports.PointerMotion{X: 199, Y: 79, DX: 5, DY: 6}
611+ for {
612+ if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
613+ 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 {
614+ t.Fatalf("motion %+v", v)
615+ }
616+ break
617+ }
618+ }
619+ // A lock keeps the pointer still.
620+ client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintLock}}
621+ scene(t, scenes)
622+ if got := receive(t, constraints); got.Mode != ports.ConstraintLock {
623+ t.Fatalf("lock %+v", got)
624+ }
625+ for len(commands) > 0 {
626+ <-commands
627+ }
628+ input <- ports.PointerMotion{X: 101, Y: 1, DX: -50}
629+ for {
630+ if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
631+ if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.DX != -50 {
632+ t.Fatalf("locked motion %+v", v)
633+ }
634+ break
635+ }
636+ }
637+ client <- ports.PointerConstrained{}
638+ scene(t, scenes)
639+ if got := receive(t, constraints); got != (ports.PointerConstraint{}) {
640+ t.Fatalf("release %+v", got)
641+ }
642+}
@@ -570,3 +570,73 @@ 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+ if got := receive(t, constraints); got != want {
604+ t.Fatalf("got %+v, want %+v", got, want)
605+ }
606+ for len(commands) > 0 {
607+ <-commands
608+ }
609+ // Motion is clamped into the region; deltas pass through.
610+ input <- ports.PointerMotion{X: 199, Y: 79, DX: 5, DY: 6}
611+ for {
612+ if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
613+ 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 {
614+ t.Fatalf("motion %+v", v)
615+ }
616+ break
617+ }
618+ }
619+ // A lock keeps the pointer still.
620+ client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintLock}}
621+ scene(t, scenes)
622+ if got := receive(t, constraints); got.Mode != ports.ConstraintLock {
623+ t.Fatalf("lock %+v", got)
624+ }
625+ for len(commands) > 0 {
626+ <-commands
627+ }
628+ input <- ports.PointerMotion{X: 101, Y: 1, DX: -50}
629+ for {
630+ if v, ok := command(t, commands).(ports.PointerMotionTo); ok {
631+ if v.X != float64(want.Rect.X+want.Rect.W-1-100-r.X) || v.DX != -50 {
632+ t.Fatalf("locked motion %+v", v)
633+ }
634+ break
635+ }
636+ }
637+ client <- ports.PointerConstrained{}
638+ scene(t, scenes)
639+ if got := receive(t, constraints); got != (ports.PointerConstraint{}) {
640+ t.Fatalf("release %+v", got)
641+ }
642+}
M internal/ports/messages.go +58 -5
@@ -83,6 +83,47 @@ 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.
100+type PointerConstraint struct {
101+ Mode ConstraintMode
102+ Rect Rect
103+}
104+
105+// Clamp keeps a global point inside a confine rectangle; other modes and an
106+// empty rectangle leave it unchanged.
107+func (c PointerConstraint) Clamp(x, y float64) (float64, float64) {
108+ if c.Mode != ConstraintConfine || c.Rect.W <= 0 || c.Rect.H <= 0 {
109+ return x, y
110+ }
111+ x = min(max(x, float64(c.Rect.X)), float64(c.Rect.X+c.Rect.W-1))
112+ y = min(max(y, float64(c.Rect.Y)), float64(c.Rect.Y+c.Rect.H-1))
113+ return x, y
114+}
115+
116+// PointerConstrained carries wayland → core the constraint active on a
117+// window (zwp_pointer_constraints_v1); ID 0 means none is active. A
118+// constraint is active only on the window with both pointer and keyboard
119+// focus.
120+type PointerConstrained struct {
121+ ID WindowID
122+ PointerConstraint
123+}
124+
125+func (PointerConstrained) clientEvent() {}
126+
86127 // Mods carries input → core modifier flags.
87128 type Mods uint8
88129
@@ -117,9 +158,15 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 }
117158 func (KeyEvent) inputEvent() {}
118159
119160 // PointerMotion uses global layout coordinates in logical pixels (see OutputPlacement).
161+// DX and DY are the accelerated logical deltas and UnaccelDX, UnaccelDY the
162+// raw device deltas; all four are 0 for absolute devices. TimeUsec is the
163+// device timestamp in microseconds.
120164 type PointerMotion struct {
121- X, Y float64
122- TimeMsec uint32
165+ X, Y float64
166+ DX, DY float64
167+ UnaccelDX, UnaccelDY float64
168+ TimeMsec uint32
169+ TimeUsec uint64
123170 }
124171
125172 func (PointerMotion) inputEvent() {}
@@ -263,10 +310,16 @@ type PointerFocus struct {
263310
264311 func (PointerFocus) clientCommand() {}
265312
313+// PointerMotionTo carries core → wayland motion for the pointer focus, in
314+// window coordinates. The deltas and TimeUsec feed zwp_relative_pointer_v1;
315+// While the window locks the pointer, wayland sends only relative motion.
266316 type PointerMotionTo struct {
267- ID WindowID
268- X, Y float64
269- TimeMsec uint32
317+ ID WindowID
318+ X, Y float64
319+ DX, DY float64
320+ UnaccelDX, UnaccelDY float64
321+ TimeMsec uint32
322+ TimeUsec uint64
270323 }
271324
272325 func (PointerMotionTo) clientCommand() {}
@@ -83,6 +83,47 @@ 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.
100+type PointerConstraint struct {
101+ Mode ConstraintMode
102+ Rect Rect
103+}
104+
105+// Clamp keeps a global point inside a confine rectangle; other modes and an
106+// empty rectangle leave it unchanged.
107+func (c PointerConstraint) Clamp(x, y float64) (float64, float64) {
108+ if c.Mode != ConstraintConfine || c.Rect.W <= 0 || c.Rect.H <= 0 {
109+ return x, y
110+ }
111+ x = min(max(x, float64(c.Rect.X)), float64(c.Rect.X+c.Rect.W-1))
112+ y = min(max(y, float64(c.Rect.Y)), float64(c.Rect.Y+c.Rect.H-1))
113+ return x, y
114+}
115+
116+// PointerConstrained carries wayland → core the constraint active on a
117+// window (zwp_pointer_constraints_v1); ID 0 means none is active. A
118+// constraint is active only on the window with both pointer and keyboard
119+// focus.
120+type PointerConstrained struct {
121+ ID WindowID
122+ PointerConstraint
123+}
124+
125+func (PointerConstrained) clientEvent() {}
126+
86 // Mods carries input → core modifier flags.127 // Mods carries input → core modifier flags.
87 type Mods uint8128 type Mods uint8
88 129
@@ -117,9 +158,15 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 }
117 func (KeyEvent) inputEvent() {}158 func (KeyEvent) inputEvent() {}
118 159
119 // PointerMotion uses global layout coordinates in logical pixels (see OutputPlacement).160 // PointerMotion uses global layout coordinates in logical pixels (see OutputPlacement).
161+// DX and DY are the accelerated logical deltas and UnaccelDX, UnaccelDY the
162+// raw device deltas; all four are 0 for absolute devices. TimeUsec is the
163+// device timestamp in microseconds.
120 type PointerMotion struct {164 type PointerMotion struct {
121- X, Y float64165+ X, Y float64
122- TimeMsec uint32166+ DX, DY float64
167+ UnaccelDX, UnaccelDY float64
168+ TimeMsec uint32
169+ TimeUsec uint64
123 }170 }
124 171
125 func (PointerMotion) inputEvent() {}172 func (PointerMotion) inputEvent() {}
@@ -263,10 +310,16 @@ type PointerFocus struct {
263 310
264 func (PointerFocus) clientCommand() {}311 func (PointerFocus) clientCommand() {}
265 312
313+// PointerMotionTo carries core → wayland motion for the pointer focus, in
314+// window coordinates. The deltas and TimeUsec feed zwp_relative_pointer_v1;
315+// While the window locks the pointer, wayland sends only relative motion.
266 type PointerMotionTo struct {316 type PointerMotionTo struct {
267- ID WindowID317+ ID WindowID
268- X, Y float64318+ X, Y float64
269- TimeMsec uint32319+ DX, DY float64
320+ UnaccelDX, UnaccelDY float64
321+ TimeMsec uint32
322+ TimeUsec uint64
270 }323 }
271 324
272 func (PointerMotionTo) clientCommand() {}325 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