detached 45fc191

fix(wayland): resend input regions after remapping

commit
45fc19192d3032d46374b160685c1ad250ba20cd
author
brice · · 2026-09-27 13:33 UTC
parent
5278439
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +138 -4
M internal/adapters/wayland/constraints.go +20 -2
@@ -277,7 +277,24 @@ func (s *Server) emitConstraint(c *constraint) {
277277 box = ports.Rect{X: x0, Y: y0, W: max(box.X+box.W, input.X+input.W) - x0, H: max(box.Y+box.H, input.Y+input.H) - y0}
278278 }
279279 }
280- r = box
280+ // Core treats an empty rect as the entire window. Keep the
281+ // original constraint (or the input box for an unbounded one)
282+ // when disjoint, rather than widening it to the whole window.
283+ if box.W > 0 {
284+ r = box
285+ } else if r.W <= 0 || r.H <= 0 {
286+ for _, input := range rects {
287+ if input.W <= 0 || input.H <= 0 {
288+ continue
289+ }
290+ if r.W <= 0 || r.H <= 0 {
291+ r = input
292+ } else {
293+ x0, y0 := min(r.X, input.X), min(r.Y, input.Y)
294+ r = ports.Rect{X: x0, Y: y0, W: max(r.X+r.W, input.X+input.W) - x0, H: max(r.Y+r.H, input.Y+input.H) - y0}
295+ }
296+ }
297+ }
281298 }
282299 }
283300 s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
@@ -290,9 +307,10 @@ func (c *constraint) contains(w *window) bool {
290307 x, y := w.surfacePoint(w.xdg.server.pointerX, w.xdg.server.pointerY)
291308 all, rects := c.surface.effectiveInput()
292309 if !all {
310+ localX, localY := x-float64(w.xdg.geometry.X), y-float64(w.xdg.geometry.Y)
293311 inside := false
294312 for _, input := range rects {
295- if x-float64(w.xdg.geometry.X) >= float64(input.X) && x-float64(w.xdg.geometry.X) < float64(input.X+input.W) && y-float64(w.xdg.geometry.Y) >= float64(input.Y) && y-float64(w.xdg.geometry.Y) < float64(input.Y+input.H) {
313+ if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {
296314 inside = true
297315 break
298316 }
@@ -277,7 +277,24 @@ func (s *Server) emitConstraint(c *constraint) {
277 box = ports.Rect{X: x0, Y: y0, W: max(box.X+box.W, input.X+input.W) - x0, H: max(box.Y+box.H, input.Y+input.H) - y0}277 box = ports.Rect{X: x0, Y: y0, W: max(box.X+box.W, input.X+input.W) - x0, H: max(box.Y+box.H, input.Y+input.H) - y0}
278 }278 }
279 }279 }
280- r = box280+ // Core treats an empty rect as the entire window. Keep the
281+ // original constraint (or the input box for an unbounded one)
282+ // when disjoint, rather than widening it to the whole window.
283+ if box.W > 0 {
284+ r = box
285+ } else if r.W <= 0 || r.H <= 0 {
286+ for _, input := range rects {
287+ if input.W <= 0 || input.H <= 0 {
288+ continue
289+ }
290+ if r.W <= 0 || r.H <= 0 {
291+ r = input
292+ } else {
293+ x0, y0 := min(r.X, input.X), min(r.Y, input.Y)
294+ r = ports.Rect{X: x0, Y: y0, W: max(r.X+r.W, input.X+input.W) - x0, H: max(r.Y+r.H, input.Y+input.H) - y0}
295+ }
296+ }
297+ }
281 }298 }
282 }299 }
283 s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})300 s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
@@ -290,9 +307,10 @@ func (c *constraint) contains(w *window) bool {
290 x, y := w.surfacePoint(w.xdg.server.pointerX, w.xdg.server.pointerY)307 x, y := w.surfacePoint(w.xdg.server.pointerX, w.xdg.server.pointerY)
291 all, rects := c.surface.effectiveInput()308 all, rects := c.surface.effectiveInput()
292 if !all {309 if !all {
310+ localX, localY := x-float64(w.xdg.geometry.X), y-float64(w.xdg.geometry.Y)
293 inside := false311 inside := false
294 for _, input := range rects {312 for _, input := range rects {
295- if x-float64(w.xdg.geometry.X) >= float64(input.X) && x-float64(w.xdg.geometry.X) < float64(input.X+input.W) && y-float64(w.xdg.geometry.Y) >= float64(input.Y) && y-float64(w.xdg.geometry.Y) < float64(input.Y+input.H) {313+ if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {
296 inside = true314 inside = true
297 break315 break
298 }316 }
M internal/adapters/wayland/input_region.go +7 -0
@@ -70,6 +70,13 @@ func (s *surface) effectiveInput() (bool, []ports.Rect) {
7070 return false, rects
7171 }
7272
73+// resetInputEmission makes the next mapping publish its current region again.
74+func (s *surface) resetInputEmission() {
75+ s.sentInput = false
76+ s.lastInputAll = false
77+ s.lastInputRects = nil
78+}
79+
7380 func (s *surface) emitInput() {
7481 root := s.root()
7582 id := root.windowID()
@@ -70,6 +70,13 @@ func (s *surface) effectiveInput() (bool, []ports.Rect) {
70 return false, rects70 return false, rects
71 }71 }
72 72
73+// resetInputEmission makes the next mapping publish its current region again.
74+func (s *surface) resetInputEmission() {
75+ s.sentInput = false
76+ s.lastInputAll = false
77+ s.lastInputRects = nil
78+}
79+
73 func (s *surface) emitInput() {80 func (s *surface) emitInput() {
74 root := s.root()81 root := s.root()
75 id := root.windowID()82 id := root.windowID()
M internal/adapters/wayland/input_region_test.go +59 -2
@@ -1,13 +1,14 @@
11 package wayland
22
33 import (
4- "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
5- "golang.org/x/sys/unix"
64 "testing"
75 "time"
86
97 "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
109 "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
11+ "golang.org/x/sys/unix"
1112 )
1213
1314 func inputChanged(t *testing.T, events <-chan ports.ClientEvent) ports.InputRegionChanged {
@@ -157,3 +158,59 @@ func TestConstraintIntersectsInputRegion(t *testing.T) {
157158 t.Fatalf("constraint intersection: %+v", got)
158159 }
159160 }
161+
162+func TestInputRegionResentAfterRemap(t *testing.T) {
163+ s, events, _, dir := lifecycleServer(t)
164+ c := protocolClient(t, s, dir)
165+ w, surf, xdg := surfaceMapper(t, c, events)()
166+ comp := bindProtocol(t, c, "wl_compositor")
167+ region := c.AllocateID()
168+ registerProtocol(t, c, region)
169+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
170+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(0), int32(0), int32(1), int32(1))
171+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetInputRegion, region)
172+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
173+ if err := c.Roundtrip(); err != nil {
174+ t.Fatal(err)
175+ }
176+ if got := inputChanged(t, events); got.ID != w.ID || got.All {
177+ t.Fatalf("initial input: %+v", got)
178+ }
179+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
180+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
181+ if err := c.Roundtrip(); err != nil {
182+ t.Fatal(err)
183+ }
184+ unmapped(t, events, w.ID)
185+ // Remapping requires a new initial configure and an ack before attaching.
186+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
187+ if err := c.Roundtrip(); err != nil {
188+ t.Fatal(err)
189+ }
190+ var serial uint32
191+ if !s.display.Do(func() {
192+ for _, v := range s.surfaces {
193+ if v.xdg != nil && v.xdg.window != nil && v.xdg.window.id == w.ID && len(v.xdg.serials) > 0 {
194+ serial = v.xdg.serials[len(v.xdg.serials)-1]
195+ }
196+ }
197+ }) {
198+ t.Fatal("server stopped")
199+ }
200+ if serial == 0 {
201+ t.Fatal("no remap configure")
202+ }
203+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
204+ buffer := shmBuffer(t, c)
205+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
206+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
207+ if err := c.Roundtrip(); err != nil {
208+ t.Fatal(err)
209+ }
210+ if got := mapped(t, events, 2*time.Second); got.ID != w.ID {
211+ t.Fatalf("remapped: %+v", got)
212+ }
213+ if got := inputChanged(t, events); got.ID != w.ID || got.All || len(got.Rects) != 1 || got.Rects[0] != (ports.Rect{W: 1, H: 1}) {
214+ t.Fatalf("remap input: %+v", got)
215+ }
216+}
@@ -1,13 +1,14 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4- "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
5- "golang.org/x/sys/unix"
6 "testing"4 "testing"
7 "time"5 "time"
8 6
9 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
10 "github.com/bnema/purego-libwayland/protocol/wayland"9 "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
11+ "golang.org/x/sys/unix"
11 )12 )
12 13
13 func inputChanged(t *testing.T, events <-chan ports.ClientEvent) ports.InputRegionChanged {14 func inputChanged(t *testing.T, events <-chan ports.ClientEvent) ports.InputRegionChanged {
@@ -157,3 +158,59 @@ func TestConstraintIntersectsInputRegion(t *testing.T) {
157 t.Fatalf("constraint intersection: %+v", got)158 t.Fatalf("constraint intersection: %+v", got)
158 }159 }
159 }160 }
161+
162+func TestInputRegionResentAfterRemap(t *testing.T) {
163+ s, events, _, dir := lifecycleServer(t)
164+ c := protocolClient(t, s, dir)
165+ w, surf, xdg := surfaceMapper(t, c, events)()
166+ comp := bindProtocol(t, c, "wl_compositor")
167+ region := c.AllocateID()
168+ registerProtocol(t, c, region)
169+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
170+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(0), int32(0), int32(1), int32(1))
171+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetInputRegion, region)
172+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
173+ if err := c.Roundtrip(); err != nil {
174+ t.Fatal(err)
175+ }
176+ if got := inputChanged(t, events); got.ID != w.ID || got.All {
177+ t.Fatalf("initial input: %+v", got)
178+ }
179+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
180+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
181+ if err := c.Roundtrip(); err != nil {
182+ t.Fatal(err)
183+ }
184+ unmapped(t, events, w.ID)
185+ // Remapping requires a new initial configure and an ack before attaching.
186+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
187+ if err := c.Roundtrip(); err != nil {
188+ t.Fatal(err)
189+ }
190+ var serial uint32
191+ if !s.display.Do(func() {
192+ for _, v := range s.surfaces {
193+ if v.xdg != nil && v.xdg.window != nil && v.xdg.window.id == w.ID && len(v.xdg.serials) > 0 {
194+ serial = v.xdg.serials[len(v.xdg.serials)-1]
195+ }
196+ }
197+ }) {
198+ t.Fatal("server stopped")
199+ }
200+ if serial == 0 {
201+ t.Fatal("no remap configure")
202+ }
203+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
204+ buffer := shmBuffer(t, c)
205+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
206+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
207+ if err := c.Roundtrip(); err != nil {
208+ t.Fatal(err)
209+ }
210+ if got := mapped(t, events, 2*time.Second); got.ID != w.ID {
211+ t.Fatalf("remapped: %+v", got)
212+ }
213+ if got := inputChanged(t, events); got.ID != w.ID || got.All || len(got.Rects) != 1 || got.Rects[0] != (ports.Rect{W: 1, H: 1}) {
214+ t.Fatalf("remap input: %+v", got)
215+ }
216+}
M internal/adapters/wayland/layer.go +1 -0
@@ -163,6 +163,7 @@ func (l *layerSurface) unmap() {
163163 s.changePointerFocus(0, 0, 0)
164164 }
165165 l.mapped = false
166+ l.surface.resetInputEmission()
166167 l.configured = false
167168 l.acked = false
168169 l.serials = nil
@@ -163,6 +163,7 @@ func (l *layerSurface) unmap() {
163 s.changePointerFocus(0, 0, 0)163 s.changePointerFocus(0, 0, 0)
164 }164 }
165 l.mapped = false165 l.mapped = false
166+ l.surface.resetInputEmission()
166 l.configured = false167 l.configured = false
167 l.acked = false168 l.acked = false
168 l.serials = nil169 l.serials = nil
M internal/adapters/wayland/xdg.go +1 -0
@@ -178,6 +178,7 @@ func (w *window) unmap() {
178178 return
179179 }
180180 w.mapped = false
181+ w.xdg.surface.resetInputEmission()
181182 w.xdg.configured = false
182183 w.xdg.acked = false
183184 w.xdg.serials = nil
@@ -178,6 +178,7 @@ func (w *window) unmap() {
178 return178 return
179 }179 }
180 w.mapped = false180 w.mapped = false
181+ w.xdg.surface.resetInputEmission()
181 w.xdg.configured = false182 w.xdg.configured = false
182 w.xdg.acked = false183 w.xdg.acked = false
183 w.xdg.serials = nil184 w.xdg.serials = nil
M internal/core/core.go +8 -0
@@ -361,16 +361,24 @@ func (c *Core) keyboardFocus() WindowID {
361361 // setLayers splits the mapped layer surfaces by output. A surface without an
362362 // output, or on an unknown one, goes to the focused output.
363363 func (c *Core) setLayers(all []ports.LayerSurface) {
364+ previous := make(map[WindowID]bool)
364365 for _, sc := range c.screens {
366+ for _, l := range sc.layers {
367+ previous[l.ID] = true
368+ }
365369 sc.layers = nil
366370 }
367371 for _, l := range all {
372+ delete(previous, l.ID)
368373 sc := c.cur()
369374 if i := c.screenIndex(l.Output); i >= 0 {
370375 sc = c.screens[i]
371376 }
372377 sc.layers = append(sc.layers, l)
373378 }
379+ for id := range previous {
380+ delete(c.inputRegions, id)
381+ }
374382 for _, sc := range c.screens {
375383 sc.arrange()
376384 }
@@ -361,16 +361,24 @@ func (c *Core) keyboardFocus() WindowID {
361 // setLayers splits the mapped layer surfaces by output. A surface without an361 // setLayers splits the mapped layer surfaces by output. A surface without an
362 // output, or on an unknown one, goes to the focused output.362 // output, or on an unknown one, goes to the focused output.
363 func (c *Core) setLayers(all []ports.LayerSurface) {363 func (c *Core) setLayers(all []ports.LayerSurface) {
364+ previous := make(map[WindowID]bool)
364 for _, sc := range c.screens {365 for _, sc := range c.screens {
366+ for _, l := range sc.layers {
367+ previous[l.ID] = true
368+ }
365 sc.layers = nil369 sc.layers = nil
366 }370 }
367 for _, l := range all {371 for _, l := range all {
372+ delete(previous, l.ID)
368 sc := c.cur()373 sc := c.cur()
369 if i := c.screenIndex(l.Output); i >= 0 {374 if i := c.screenIndex(l.Output); i >= 0 {
370 sc = c.screens[i]375 sc = c.screens[i]
371 }376 }
372 sc.layers = append(sc.layers, l)377 sc.layers = append(sc.layers, l)
373 }378 }
379+ for id := range previous {
380+ delete(c.inputRegions, id)
381+ }
374 for _, sc := range c.screens {382 for _, sc := range c.screens {
375 sc.arrange()383 sc.arrange()
376 }384 }
M internal/core/layer_input_test.go +42 -0
@@ -315,3 +315,45 @@ func TestLayerInputRegionPassThrough(t *testing.T) {
315315 t.Fatalf("reset focus: %+v", v)
316316 }
317317 }
318+
319+func TestRemovedLayerInputRegionCleared(t *testing.T) {
320+ cfg := config.Defaults()
321+ cfg.Border.Width = 0
322+ client := make(chan ports.ClientEvent, 16)
323+ input := make(chan ports.InputEvent, 16)
324+ output := make(chan ports.OutputEvent, 16)
325+ commands := make(chan ports.ClientCommand, 256)
326+ scenes := make(chan []ports.Scene, 1)
327+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
328+ if err != nil {
329+ t.Fatal(err)
330+ }
331+ ctx, cancel := context.WithCancel(context.Background())
332+ defer cancel()
333+ go c.Run(ctx)
334+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
335+ scene(t, scenes)
336+ client <- ports.WindowMapped{ID: 1}
337+ scene(t, scenes)
338+ client <- ports.InputRegionChanged{ID: 1}
339+ scene(t, scenes) // window's empty region must survive layer changes
340+ layer := ports.LayerSurface{ID: 6, Layer: ports.LayerOverlay, Width: 100, Height: 80}
341+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{layer}}
342+ scene(t, scenes)
343+ client <- ports.InputRegionChanged{ID: 6}
344+ scene(t, scenes)
345+ client <- ports.LayerChanged{}
346+ scene(t, scenes)
347+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{layer}}
348+ scene(t, scenes)
349+ input <- ports.PointerMotion{X: 20, Y: 20}
350+ if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 {
351+ t.Fatalf("remapped layer retains old region: %+v", v)
352+ }
353+ client <- ports.LayerChanged{}
354+ scene(t, scenes)
355+ input <- ports.PointerMotion{X: 21, Y: 21}
356+ if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 0 {
357+ t.Fatalf("window region removed with layer: %+v", v)
358+ }
359+}
@@ -315,3 +315,45 @@ func TestLayerInputRegionPassThrough(t *testing.T) {
315 t.Fatalf("reset focus: %+v", v)315 t.Fatalf("reset focus: %+v", v)
316 }316 }
317 }317 }
318+
319+func TestRemovedLayerInputRegionCleared(t *testing.T) {
320+ cfg := config.Defaults()
321+ cfg.Border.Width = 0
322+ client := make(chan ports.ClientEvent, 16)
323+ input := make(chan ports.InputEvent, 16)
324+ output := make(chan ports.OutputEvent, 16)
325+ commands := make(chan ports.ClientCommand, 256)
326+ scenes := make(chan []ports.Scene, 1)
327+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
328+ if err != nil {
329+ t.Fatal(err)
330+ }
331+ ctx, cancel := context.WithCancel(context.Background())
332+ defer cancel()
333+ go c.Run(ctx)
334+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
335+ scene(t, scenes)
336+ client <- ports.WindowMapped{ID: 1}
337+ scene(t, scenes)
338+ client <- ports.InputRegionChanged{ID: 1}
339+ scene(t, scenes) // window's empty region must survive layer changes
340+ layer := ports.LayerSurface{ID: 6, Layer: ports.LayerOverlay, Width: 100, Height: 80}
341+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{layer}}
342+ scene(t, scenes)
343+ client <- ports.InputRegionChanged{ID: 6}
344+ scene(t, scenes)
345+ client <- ports.LayerChanged{}
346+ scene(t, scenes)
347+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{layer}}
348+ scene(t, scenes)
349+ input <- ports.PointerMotion{X: 20, Y: 20}
350+ if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 6 {
351+ t.Fatalf("remapped layer retains old region: %+v", v)
352+ }
353+ client <- ports.LayerChanged{}
354+ scene(t, scenes)
355+ input <- ports.PointerMotion{X: 21, Y: 21}
356+ if v := next(t, commands, anyOf[ports.PointerFocus]); v.ID != 0 {
357+ t.Fatalf("window region removed with layer: %+v", v)
358+ }
359+}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close