detached 16be8cb

fix(layer): on-demand focus survives the pointer moving to another output

commit
16be8cb34587bbe5f77a71b9d2aae42645319a03
author
brice · · 2026-09-26 06:03 UTC
committer
brice · · 2026-09-26 06:05 UTC
parent
e2fd4a0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +50 -4
M internal/core/core.go +11 -4
@@ -87,6 +87,9 @@ type Core struct {
8787 // new window takes the keyboard back.
8888 layerFocus WindowID
8989 layerOver WindowID
90+ // layerScreen is the output focused at the click: the pointer moving to
91+ // another output does not end the layer's focus.
92+ layerScreen *screen
9093 }
9194
9295 func keyName(s string) string {
@@ -322,13 +325,16 @@ func (c *Core) keyboardFocus() WindowID {
322325 if g := c.grabFocus(); g != 0 {
323326 return g
324327 }
325- id, _ := c.cur().mon.Focused()
326- if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || id != c.layerOver) {
327- c.layerFocus = 0
328+ if c.layerFocus != 0 {
329+ over, _ := c.layerScreen.mon.Focused()
330+ if !c.onDemand(c.layerFocus) || !slices.Contains(c.screens, c.layerScreen) || over != c.layerOver {
331+ c.layerFocus, c.layerScreen = 0, nil
332+ }
328333 }
329334 if c.layerFocus != 0 {
330335 return c.layerFocus
331336 }
337+ id, _ := c.cur().mon.Focused()
332338 return id
333339 }
334340
@@ -747,7 +753,8 @@ func (c *Core) Run(ctx context.Context) error {
747753 // A click on an on-demand layer gives it the keyboard.
748754 if v.Pressed && c.onDemand(id) && c.layerFocus != id {
749755 c.layerFocus = id
750- c.layerOver, _ = c.cur().mon.Focused()
756+ c.layerScreen = c.cur()
757+ c.layerOver, _ = c.layerScreen.mon.Focused()
751758 if err := c.publish(ctx); err != nil {
752759 return nil
753760 }
@@ -87,6 +87,9 @@ type Core struct {
87 // new window takes the keyboard back.87 // new window takes the keyboard back.
88 layerFocus WindowID88 layerFocus WindowID
89 layerOver WindowID89 layerOver WindowID
90+ // layerScreen is the output focused at the click: the pointer moving to
91+ // another output does not end the layer's focus.
92+ layerScreen *screen
90 }93 }
91 94
92 func keyName(s string) string {95 func keyName(s string) string {
@@ -322,13 +325,16 @@ func (c *Core) keyboardFocus() WindowID {
322 if g := c.grabFocus(); g != 0 {325 if g := c.grabFocus(); g != 0 {
323 return g326 return g
324 }327 }
325- id, _ := c.cur().mon.Focused()328+ if c.layerFocus != 0 {
326- if c.layerFocus != 0 && (!c.onDemand(c.layerFocus) || id != c.layerOver) {329+ over, _ := c.layerScreen.mon.Focused()
327- c.layerFocus = 0330+ if !c.onDemand(c.layerFocus) || !slices.Contains(c.screens, c.layerScreen) || over != c.layerOver {
331+ c.layerFocus, c.layerScreen = 0, nil
332+ }
328 }333 }
329 if c.layerFocus != 0 {334 if c.layerFocus != 0 {
330 return c.layerFocus335 return c.layerFocus
331 }336 }
337+ id, _ := c.cur().mon.Focused()
332 return id338 return id
333 }339 }
334 340
@@ -747,7 +753,8 @@ func (c *Core) Run(ctx context.Context) error {
747 // A click on an on-demand layer gives it the keyboard.753 // A click on an on-demand layer gives it the keyboard.
748 if v.Pressed && c.onDemand(id) && c.layerFocus != id {754 if v.Pressed && c.onDemand(id) && c.layerFocus != id {
749 c.layerFocus = id755 c.layerFocus = id
750- c.layerOver, _ = c.cur().mon.Focused()756+ c.layerScreen = c.cur()
757+ c.layerOver, _ = c.layerScreen.mon.Focused()
751 if err := c.publish(ctx); err != nil {758 if err := c.publish(ctx); err != nil {
752 return nil759 return nil
753 }760 }
M internal/core/layer_input_test.go +39 -0
@@ -220,3 +220,42 @@ func TestLayerHitOrder(t *testing.T) {
220220 hit(50, 5, 1)
221221 hit(50, 75, 7)
222222 }
223+
224+// The pointer moving to another output does not take the keyboard from a
225+// clicked on-demand layer: keys still reach it.
226+func TestLayerFocusAcrossOutputs(t *testing.T) {
227+ cfg := config.Defaults()
228+ cfg.Border.Width = 0
229+ client := make(chan ports.ClientEvent, 8)
230+ input := make(chan ports.InputEvent, 8)
231+ output := make(chan ports.OutputEvent, 8)
232+ commands := make(chan ports.ClientCommand, 256)
233+ scenes := make(chan []ports.Scene, 1)
234+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
235+ if err != nil {
236+ t.Fatal(err)
237+ }
238+ ctx, cancel := context.WithCancel(context.Background())
239+ defer cancel()
240+ go c.Run(ctx)
241+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
242+ scene(t, scenes)
243+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
244+ scene(t, scenes)
245+ client <- ports.WindowMapped{ID: 1}
246+ scene(t, scenes)
247+ bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, Keyboard: 2, Output: "OUT-1"}
248+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
249+ scene(t, scenes)
250+ input <- ports.PointerMotion{X: 50, Y: 5}
251+ next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 })
252+ input <- ports.PointerButton{Button: 0x110, Pressed: true}
253+ next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 })
254+ input <- ports.PointerButton{Button: 0x110}
255+ input <- ports.PointerMotion{X: 100, Y: 40} // onto OUT-2, now focused
256+ next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 0 })
257+ input <- ports.KeyEvent{Keysym: "a", Pressed: true}
258+ if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 6 {
259+ t.Fatalf("key to %d, want the bar", v.ID)
260+ }
261+}
@@ -220,3 +220,42 @@ func TestLayerHitOrder(t *testing.T) {
220 hit(50, 5, 1)220 hit(50, 5, 1)
221 hit(50, 75, 7)221 hit(50, 75, 7)
222 }222 }
223+
224+// The pointer moving to another output does not take the keyboard from a
225+// clicked on-demand layer: keys still reach it.
226+func TestLayerFocusAcrossOutputs(t *testing.T) {
227+ cfg := config.Defaults()
228+ cfg.Border.Width = 0
229+ client := make(chan ports.ClientEvent, 8)
230+ input := make(chan ports.InputEvent, 8)
231+ output := make(chan ports.OutputEvent, 8)
232+ commands := make(chan ports.ClientCommand, 256)
233+ scenes := make(chan []ports.Scene, 1)
234+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
235+ if err != nil {
236+ t.Fatal(err)
237+ }
238+ ctx, cancel := context.WithCancel(context.Background())
239+ defer cancel()
240+ go c.Run(ctx)
241+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
242+ scene(t, scenes)
243+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
244+ scene(t, scenes)
245+ client <- ports.WindowMapped{ID: 1}
246+ scene(t, scenes)
247+ bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, Keyboard: 2, Output: "OUT-1"}
248+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
249+ scene(t, scenes)
250+ input <- ports.PointerMotion{X: 50, Y: 5}
251+ next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 6 })
252+ input <- ports.PointerButton{Button: 0x110, Pressed: true}
253+ next(t, commands, func(v ports.FocusWindow) bool { return v.ID == 6 })
254+ input <- ports.PointerButton{Button: 0x110}
255+ input <- ports.PointerMotion{X: 100, Y: 40} // onto OUT-2, now focused
256+ next(t, commands, func(v ports.PointerFocus) bool { return v.ID == 0 })
257+ input <- ports.KeyEvent{Keysym: "a", Pressed: true}
258+ if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 6 {
259+ t.Fatalf("key to %d, want the bar", v.ID)
260+ }
261+}

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 26

Tags 10

↑↓ moveenter openesc close