detached adaacbc

fix(wayland): deactivate constraints outside committed input

commit
adaacbc4461c12d356e9732b8aeab2d38f26021e
author
brice · · 2026-09-27 14:02 UTC
parent
45fc191
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +176 -35
M internal/adapters/wayland/constraints.go +25 -34
@@ -205,6 +205,26 @@ func (s *Server) dropConstraint(c *constraint) {
205205 s.updateConstraint()
206206 }
207207
208+// deactivateConstraint ends the active constraint; a oneshot cannot reactivate.
209+func (s *Server) deactivateConstraint() {
210+ old := s.constraint
211+ if old == nil {
212+ return
213+ }
214+ old.active = false
215+ if !old.persistent {
216+ old.defunct = true
217+ }
218+ if old.lock != nil && old.lock.Resource.Alive() {
219+ old.lock.SendUnlocked()
220+ }
221+ if old.confine != nil && old.confine.Resource.Alive() {
222+ old.confine.SendUnconfined()
223+ }
224+ s.constraint = nil
225+ s.emit(ports.PointerConstrained{})
226+}
227+
208228 // updateConstraint activates the constraint of the window holding both
209229 // pointer and keyboard focus, and deactivates any other.
210230 func (s *Server) updateConstraint() {
@@ -217,23 +237,11 @@ func (s *Server) updateConstraint() {
217237 if want == s.constraint {
218238 return
219239 }
220- if old := s.constraint; old != nil {
221- old.active = false
222- if !old.persistent {
223- old.defunct = true
224- }
225- if old.lock != nil && old.lock.Resource.Alive() {
226- old.lock.SendUnlocked()
227- }
228- if old.confine != nil && old.confine.Resource.Alive() {
229- old.confine.SendUnconfined()
230- }
231- }
232- s.constraint = want
240+ s.deactivateConstraint()
233241 if want == nil {
234- s.emit(ports.PointerConstrained{})
235242 return
236243 }
244+ s.constraint = want
237245 want.active = true
238246 if want.lock != nil {
239247 want.lock.SendLocked()
@@ -258,8 +266,8 @@ func (s *Server) emitConstraint(c *constraint) {
258266 r.X -= x.geometry.X
259267 r.Y -= x.geometry.Y
260268 }
261- // The input region also bounds confinement. A box is sufficient for core;
262- // contains uses the exact region to decide activation.
269+ // Core supports one confinement rect, so this bounds the intersection;
270+ // contains uses the exact input region to decide activation.
263271 if mode == ports.ConstraintConfine {
264272 if all, rects := c.surface.effectiveInput(); !all {
265273 var box ports.Rect
@@ -277,24 +285,7 @@ func (s *Server) emitConstraint(c *constraint) {
277285 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}
278286 }
279287 }
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- }
288+ r = box
298289 }
299290 }
300291 s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
@@ -205,6 +205,26 @@ func (s *Server) dropConstraint(c *constraint) {
205 s.updateConstraint()205 s.updateConstraint()
206 }206 }
207 207
208+// deactivateConstraint ends the active constraint; a oneshot cannot reactivate.
209+func (s *Server) deactivateConstraint() {
210+ old := s.constraint
211+ if old == nil {
212+ return
213+ }
214+ old.active = false
215+ if !old.persistent {
216+ old.defunct = true
217+ }
218+ if old.lock != nil && old.lock.Resource.Alive() {
219+ old.lock.SendUnlocked()
220+ }
221+ if old.confine != nil && old.confine.Resource.Alive() {
222+ old.confine.SendUnconfined()
223+ }
224+ s.constraint = nil
225+ s.emit(ports.PointerConstrained{})
226+}
227+
208 // updateConstraint activates the constraint of the window holding both228 // updateConstraint activates the constraint of the window holding both
209 // pointer and keyboard focus, and deactivates any other.229 // pointer and keyboard focus, and deactivates any other.
210 func (s *Server) updateConstraint() {230 func (s *Server) updateConstraint() {
@@ -217,23 +237,11 @@ func (s *Server) updateConstraint() {
217 if want == s.constraint {237 if want == s.constraint {
218 return238 return
219 }239 }
220- if old := s.constraint; old != nil {240+ s.deactivateConstraint()
221- old.active = false
222- if !old.persistent {
223- old.defunct = true
224- }
225- if old.lock != nil && old.lock.Resource.Alive() {
226- old.lock.SendUnlocked()
227- }
228- if old.confine != nil && old.confine.Resource.Alive() {
229- old.confine.SendUnconfined()
230- }
231- }
232- s.constraint = want
233 if want == nil {241 if want == nil {
234- s.emit(ports.PointerConstrained{})
235 return242 return
236 }243 }
244+ s.constraint = want
237 want.active = true245 want.active = true
238 if want.lock != nil {246 if want.lock != nil {
239 want.lock.SendLocked()247 want.lock.SendLocked()
@@ -258,8 +266,8 @@ func (s *Server) emitConstraint(c *constraint) {
258 r.X -= x.geometry.X266 r.X -= x.geometry.X
259 r.Y -= x.geometry.Y267 r.Y -= x.geometry.Y
260 }268 }
261- // The input region also bounds confinement. A box is sufficient for core;269+ // Core supports one confinement rect, so this bounds the intersection;
262- // contains uses the exact region to decide activation.270+ // contains uses the exact input region to decide activation.
263 if mode == ports.ConstraintConfine {271 if mode == ports.ConstraintConfine {
264 if all, rects := c.surface.effectiveInput(); !all {272 if all, rects := c.surface.effectiveInput(); !all {
265 var box ports.Rect273 var box ports.Rect
@@ -277,24 +285,7 @@ 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}285 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 }286 }
279 }287 }
280- // Core treats an empty rect as the entire window. Keep the288+ r = box
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- }
298 }289 }
299 }290 }
300 s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})291 s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
M internal/adapters/wayland/input_region.go +6 -1
@@ -96,7 +96,12 @@ func (s *surface) emitInput() {
9696 s.server.emit(ports.InputRegionChanged{ID: id, All: all, Rects: rects})
9797 if c := s.server.constraints[root]; c != nil {
9898 if c.active {
99- s.server.emitConstraint(c)
99+ w := s.server.windows[id]
100+ if w == nil || !c.contains(w) {
101+ s.server.deactivateConstraint()
102+ } else {
103+ s.server.emitConstraint(c)
104+ }
100105 } else {
101106 s.server.updateConstraint()
102107 }
@@ -96,7 +96,12 @@ func (s *surface) emitInput() {
96 s.server.emit(ports.InputRegionChanged{ID: id, All: all, Rects: rects})96 s.server.emit(ports.InputRegionChanged{ID: id, All: all, Rects: rects})
97 if c := s.server.constraints[root]; c != nil {97 if c := s.server.constraints[root]; c != nil {
98 if c.active {98 if c.active {
99- s.server.emitConstraint(c)99+ w := s.server.windows[id]
100+ if w == nil || !c.contains(w) {
101+ s.server.deactivateConstraint()
102+ } else {
103+ s.server.emitConstraint(c)
104+ }
100 } else {105 } else {
101 s.server.updateConstraint()106 s.server.updateConstraint()
102 }107 }
M internal/adapters/wayland/input_region_test.go +109 -0
@@ -1,10 +1,12 @@
11 package wayland
22
33 import (
4+ "fmt"
45 "testing"
56 "time"
67
78 "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/protocol/fifo"
810 "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
911 "github.com/bnema/purego-libwayland/protocol/wayland"
1012 "github.com/bnema/purego-libwayland/protocol/xdgshell"
@@ -214,3 +216,110 @@ func TestInputRegionResentAfterRemap(t *testing.T) {
214216 t.Fatalf("remap input: %+v", got)
215217 }
216218 }
219+
220+func TestRegionOverlappingAddsSubtractUnion(t *testing.T) {
221+ g := &region{}
222+ g.Add(nil, 0, 0, 6, 6)
223+ g.Add(nil, 2, 2, 6, 6)
224+ g.Subtract(nil, 3, 3, 2, 2)
225+ for _, r := range g.rects {
226+ if intersectRect(r, ports.Rect{X: 3, Y: 3, W: 2, H: 2}).W != 0 {
227+ t.Fatalf("subtracted overlap remains: %+v", g.rects)
228+ }
229+ }
230+ if g.boxRect() != (ports.Rect{W: 8, H: 8}) {
231+ t.Fatalf("outer region changed: %+v", g.rects)
232+ }
233+}
234+
235+func TestInputRegionDeactivatesConstraint(t *testing.T) {
236+ for _, kind := range []string{"confine", "lock"} {
237+ for _, persistent := range []bool{false, true} {
238+ t.Run(fmt.Sprintf("%s/persistent=%t", kind, persistent), func(t *testing.T) {
239+ s, events, commands, dir := lifecycleServer(t)
240+ c := protocolClient(t, s, dir)
241+ seat := bindProtocol(t, c, "wl_seat")
242+ registerProtocol(t, c, seat)
243+ pointer := c.AllocateID()
244+ registerProtocol(t, c, pointer)
245+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
246+ w, surf, _ := surfaceMapper(t, c, events)()
247+ manager := bindProtocol(t, c, "zwp_pointer_constraints_v1")
248+ id := c.AllocateID()
249+ notices := logProxy(t, c, id)
250+ lifetime := uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot)
251+ if persistent {
252+ lifetime = uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent)
253+ }
254+ if kind == "lock" {
255+ requestProtocol(t, c, manager, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, id, surf, pointer, uint32(0), lifetime)
256+ } else {
257+ requestProtocol(t, c, manager, pointerconstraints.ZwpPointerConstraintsV1RequestConfinePointer, id, surf, pointer, uint32(0), lifetime)
258+ }
259+ commands <- ports.PointerFocus{ID: w.ID}
260+ commands <- ports.FocusWindow{ID: w.ID}
261+ if ev := next(t, c, notices); ev[0] != 0 {
262+ t.Fatalf("activation: %v", ev)
263+ }
264+ if got := constrained(t, events); got.ID != w.ID {
265+ t.Fatalf("constraint: %+v", got)
266+ }
267+ comp := bindProtocol(t, c, "wl_compositor")
268+ region := c.AllocateID()
269+ registerProtocol(t, c, region)
270+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
271+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetInputRegion, region)
272+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
273+ if ev := next(t, c, notices); ev[0] != 1 {
274+ t.Fatalf("deactivation: %v", ev)
275+ }
276+ if got := constrained(t, events); got.ID != 0 {
277+ t.Fatalf("constraint retained: %+v", got)
278+ }
279+ if err := c.Roundtrip(); err != nil {
280+ t.Fatal(err)
281+ }
282+ select {
283+ case ev := <-notices:
284+ t.Fatalf("unexpected reactivation: %v", ev)
285+ default:
286+ }
287+ })
288+ }
289+ }
290+}
291+
292+func TestFifoQueuedInputRegionsApplyInOrder(t *testing.T) {
293+ s, events, _, dir := lifecycleServer(t)
294+ c := protocolClient(t, s, dir)
295+ w, surf, _ := surfaceMapper(t, c, events)()
296+ manager := bindProtocol(t, c, "wp_fifo_manager_v1")
297+ f := c.AllocateID()
298+ registerProtocol(t, c, f)
299+ requestProtocol(t, c, manager, fifo.WpFifoManagerV1RequestGetFifo, f, surf)
300+ comp := bindProtocol(t, c, "wl_compositor")
301+ region := c.AllocateID()
302+ registerProtocol(t, c, region)
303+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
304+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(0), int32(0), int32(1), int32(1))
305+ // First commit sets a barrier; the next two wait behind it.
306+ requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)
307+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
308+ requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier)
309+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetInputRegion, region)
310+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
311+ requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier)
312+ empty := c.AllocateID()
313+ registerProtocol(t, c, empty)
314+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, empty)
315+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetInputRegion, empty)
316+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
317+ if err := c.Roundtrip(); err != nil {
318+ t.Fatal(err)
319+ }
320+ first := inputChanged(t, events)
321+ second := inputChanged(t, events)
322+ if first.ID != w.ID || len(first.Rects) != 1 || first.Rects[0] != (ports.Rect{W: 1, H: 1}) || second.ID != w.ID || second.All || len(second.Rects) != 0 {
323+ t.Fatalf("queued input order: first %+v, second %+v", first, second)
324+ }
325+}
@@ -1,10 +1,12 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "fmt"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/purego-libwayland/protocol/fifo"
8 "github.com/bnema/purego-libwayland/protocol/pointerconstraints"10 "github.com/bnema/purego-libwayland/protocol/pointerconstraints"
9 "github.com/bnema/purego-libwayland/protocol/wayland"11 "github.com/bnema/purego-libwayland/protocol/wayland"
10 "github.com/bnema/purego-libwayland/protocol/xdgshell"12 "github.com/bnema/purego-libwayland/protocol/xdgshell"
@@ -214,3 +216,110 @@ func TestInputRegionResentAfterRemap(t *testing.T) {
214 t.Fatalf("remap input: %+v", got)216 t.Fatalf("remap input: %+v", got)
215 }217 }
216 }218 }
219+
220+func TestRegionOverlappingAddsSubtractUnion(t *testing.T) {
221+ g := &region{}
222+ g.Add(nil, 0, 0, 6, 6)
223+ g.Add(nil, 2, 2, 6, 6)
224+ g.Subtract(nil, 3, 3, 2, 2)
225+ for _, r := range g.rects {
226+ if intersectRect(r, ports.Rect{X: 3, Y: 3, W: 2, H: 2}).W != 0 {
227+ t.Fatalf("subtracted overlap remains: %+v", g.rects)
228+ }
229+ }
230+ if g.boxRect() != (ports.Rect{W: 8, H: 8}) {
231+ t.Fatalf("outer region changed: %+v", g.rects)
232+ }
233+}
234+
235+func TestInputRegionDeactivatesConstraint(t *testing.T) {
236+ for _, kind := range []string{"confine", "lock"} {
237+ for _, persistent := range []bool{false, true} {
238+ t.Run(fmt.Sprintf("%s/persistent=%t", kind, persistent), func(t *testing.T) {
239+ s, events, commands, dir := lifecycleServer(t)
240+ c := protocolClient(t, s, dir)
241+ seat := bindProtocol(t, c, "wl_seat")
242+ registerProtocol(t, c, seat)
243+ pointer := c.AllocateID()
244+ registerProtocol(t, c, pointer)
245+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
246+ w, surf, _ := surfaceMapper(t, c, events)()
247+ manager := bindProtocol(t, c, "zwp_pointer_constraints_v1")
248+ id := c.AllocateID()
249+ notices := logProxy(t, c, id)
250+ lifetime := uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimeOneshot)
251+ if persistent {
252+ lifetime = uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent)
253+ }
254+ if kind == "lock" {
255+ requestProtocol(t, c, manager, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, id, surf, pointer, uint32(0), lifetime)
256+ } else {
257+ requestProtocol(t, c, manager, pointerconstraints.ZwpPointerConstraintsV1RequestConfinePointer, id, surf, pointer, uint32(0), lifetime)
258+ }
259+ commands <- ports.PointerFocus{ID: w.ID}
260+ commands <- ports.FocusWindow{ID: w.ID}
261+ if ev := next(t, c, notices); ev[0] != 0 {
262+ t.Fatalf("activation: %v", ev)
263+ }
264+ if got := constrained(t, events); got.ID != w.ID {
265+ t.Fatalf("constraint: %+v", got)
266+ }
267+ comp := bindProtocol(t, c, "wl_compositor")
268+ region := c.AllocateID()
269+ registerProtocol(t, c, region)
270+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
271+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetInputRegion, region)
272+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
273+ if ev := next(t, c, notices); ev[0] != 1 {
274+ t.Fatalf("deactivation: %v", ev)
275+ }
276+ if got := constrained(t, events); got.ID != 0 {
277+ t.Fatalf("constraint retained: %+v", got)
278+ }
279+ if err := c.Roundtrip(); err != nil {
280+ t.Fatal(err)
281+ }
282+ select {
283+ case ev := <-notices:
284+ t.Fatalf("unexpected reactivation: %v", ev)
285+ default:
286+ }
287+ })
288+ }
289+ }
290+}
291+
292+func TestFifoQueuedInputRegionsApplyInOrder(t *testing.T) {
293+ s, events, _, dir := lifecycleServer(t)
294+ c := protocolClient(t, s, dir)
295+ w, surf, _ := surfaceMapper(t, c, events)()
296+ manager := bindProtocol(t, c, "wp_fifo_manager_v1")
297+ f := c.AllocateID()
298+ registerProtocol(t, c, f)
299+ requestProtocol(t, c, manager, fifo.WpFifoManagerV1RequestGetFifo, f, surf)
300+ comp := bindProtocol(t, c, "wl_compositor")
301+ region := c.AllocateID()
302+ registerProtocol(t, c, region)
303+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, region)
304+ requestProtocol(t, c, region, wayland.RegionRequestAdd, int32(0), int32(0), int32(1), int32(1))
305+ // First commit sets a barrier; the next two wait behind it.
306+ requestProtocol(t, c, f, fifo.WpFifoV1RequestSetBarrier)
307+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
308+ requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier)
309+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetInputRegion, region)
310+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
311+ requestProtocol(t, c, f, fifo.WpFifoV1RequestWaitBarrier)
312+ empty := c.AllocateID()
313+ registerProtocol(t, c, empty)
314+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateRegion, empty)
315+ requestProtocol(t, c, surf, wayland.SurfaceRequestSetInputRegion, empty)
316+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
317+ if err := c.Roundtrip(); err != nil {
318+ t.Fatal(err)
319+ }
320+ first := inputChanged(t, events)
321+ second := inputChanged(t, events)
322+ if first.ID != w.ID || len(first.Rects) != 1 || first.Rects[0] != (ports.Rect{W: 1, H: 1}) || second.ID != w.ID || second.All || len(second.Rects) != 0 {
323+ t.Fatalf("queued input order: first %+v, second %+v", first, second)
324+ }
325+}
M internal/core/layer_input_test.go +36 -0
@@ -357,3 +357,39 @@ func TestRemovedLayerInputRegionCleared(t *testing.T) {
357357 t.Fatalf("window region removed with layer: %+v", v)
358358 }
359359 }
360+
361+func TestPopupInputRegionPassesThroughToWindow(t *testing.T) {
362+ cfg := config.Defaults()
363+ cfg.Border.Width = 0
364+ client := make(chan ports.ClientEvent, 16)
365+ input := make(chan ports.InputEvent, 16)
366+ output := make(chan ports.OutputEvent, 16)
367+ commands := make(chan ports.ClientCommand, 256)
368+ scenes := make(chan []ports.Scene, 1)
369+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
370+ if err != nil {
371+ t.Fatal(err)
372+ }
373+ ctx, cancel := context.WithCancel(context.Background())
374+ defer cancel()
375+ go c.Run(ctx)
376+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
377+ scene(t, scenes)
378+ client <- ports.WindowMapped{ID: 1}
379+ scene(t, scenes)
380+ client <- ports.PopupRequest{ID: 9, Parent: 1, Positioner: ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 10, Y: 10, W: 10, H: 10}}}
381+ next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 })
382+ client <- ports.PopupMapped{ID: 9}
383+ sc := sceneMatch(t, scenes, func(sc ports.Scene) bool { return len(sc.Windows) == 2 })
384+ client <- ports.InputRegionChanged{ID: 9, Rects: []ports.Rect{{X: 10, Y: 0, W: 20, H: 20}}}
385+ scene(t, scenes)
386+ popup := sc.Windows[1].Rect
387+ input <- ports.PointerMotion{X: float64(popup.X + 2), Y: float64(popup.Y + 2)}
388+ if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 1 {
389+ t.Fatalf("popup margin motion: %+v, popup %+v", v, popup)
390+ }
391+ input <- ports.PointerMotion{X: float64(popup.X + 12), Y: float64(popup.Y + 2)}
392+ if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 9 {
393+ t.Fatalf("popup interior motion: %+v", v)
394+ }
395+}
@@ -357,3 +357,39 @@ func TestRemovedLayerInputRegionCleared(t *testing.T) {
357 t.Fatalf("window region removed with layer: %+v", v)357 t.Fatalf("window region removed with layer: %+v", v)
358 }358 }
359 }359 }
360+
361+func TestPopupInputRegionPassesThroughToWindow(t *testing.T) {
362+ cfg := config.Defaults()
363+ cfg.Border.Width = 0
364+ client := make(chan ports.ClientEvent, 16)
365+ input := make(chan ports.InputEvent, 16)
366+ output := make(chan ports.OutputEvent, 16)
367+ commands := make(chan ports.ClientCommand, 256)
368+ scenes := make(chan []ports.Scene, 1)
369+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
370+ if err != nil {
371+ t.Fatal(err)
372+ }
373+ ctx, cancel := context.WithCancel(context.Background())
374+ defer cancel()
375+ go c.Run(ctx)
376+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
377+ scene(t, scenes)
378+ client <- ports.WindowMapped{ID: 1}
379+ scene(t, scenes)
380+ client <- ports.PopupRequest{ID: 9, Parent: 1, Positioner: ports.Positioner{Width: 30, Height: 20, AnchorRect: ports.Rect{X: 10, Y: 10, W: 10, H: 10}}}
381+ next(t, commands, func(v ports.ConfigurePopup) bool { return v.ID == 9 })
382+ client <- ports.PopupMapped{ID: 9}
383+ sc := sceneMatch(t, scenes, func(sc ports.Scene) bool { return len(sc.Windows) == 2 })
384+ client <- ports.InputRegionChanged{ID: 9, Rects: []ports.Rect{{X: 10, Y: 0, W: 20, H: 20}}}
385+ scene(t, scenes)
386+ popup := sc.Windows[1].Rect
387+ input <- ports.PointerMotion{X: float64(popup.X + 2), Y: float64(popup.Y + 2)}
388+ if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 1 {
389+ t.Fatalf("popup margin motion: %+v, popup %+v", v, popup)
390+ }
391+ input <- ports.PointerMotion{X: float64(popup.X + 12), Y: float64(popup.Y + 2)}
392+ if v := next(t, commands, anyOf[ports.PointerMotionTo]); v.ID != 9 {
393+ t.Fatalf("popup interior motion: %+v", v)
394+ }
395+}

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