detached 2172e12

merge: launcher (spawn bind action, layer keyboard focus)

commit
2172e121b874e5b9590517284c8c5fdab7a55387
author
brice · · 2026-09-24 12:16 UTC
merge of
4dd986f + 4c23646 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +166 -24
M examples/config.toml +2 -0
@@ -54,3 +54,5 @@ debug = []
5454 "Cmd+Shift+F" = "toggle-fullscreen"
5555 "Cmd+Q" = "close-window"
5656 "Ctrl+Alt+BackSpace" = "quit"
57+# "spawn <command>" runs a program; arguments split on spaces, no shell.
58+"Ctrl+Cmd+space" = "spawn fuzzel"
@@ -54,3 +54,5 @@ debug = []
54 "Cmd+Shift+F" = "toggle-fullscreen"54 "Cmd+Shift+F" = "toggle-fullscreen"
55 "Cmd+Q" = "close-window"55 "Cmd+Q" = "close-window"
56 "Ctrl+Alt+BackSpace" = "quit"56 "Ctrl+Alt+BackSpace" = "quit"
57+# "spawn <command>" runs a program; arguments split on spaces, no shell.
58+"Ctrl+Cmd+space" = "spawn fuzzel"
M internal/adapters/config/config.go +7 -3
@@ -15,7 +15,7 @@ import (
1515 )
1616
1717 var defaultBinds = map[string]string{
18- "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",
18+ "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", "Ctrl+Cmd+space": "spawn fuzzel",
1919 }
2020 var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
2121 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
@@ -270,8 +270,12 @@ func Validate(c ports.Config) error {
270270 combos[n] = k
271271 }
272272 }
273- if !actions[v] {
274- add("binds."+k, "invalid action")
273+ if rest, ok := strings.CutPrefix(v, "spawn "); ok {
274+ if len(strings.Fields(rest)) == 0 {
275+ add("binds."+k, "spawn needs a command")
276+ }
277+ } else if !actions[v] {
278+ add("binds."+k, `invalid action (or "spawn <command>")`)
275279 }
276280 }
277281 return errors.Join(errs...)
@@ -15,7 +15,7 @@ import (
15 )15 )
16 16
17 var defaultBinds = map[string]string{17 var defaultBinds = map[string]string{
18- "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",18+ "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit", "Ctrl+Cmd+space": "spawn fuzzel",
19 }19 }
20 var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}20 var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
21 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}21 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
@@ -270,8 +270,12 @@ func Validate(c ports.Config) error {
270 combos[n] = k270 combos[n] = k
271 }271 }
272 }272 }
273- if !actions[v] {273+ if rest, ok := strings.CutPrefix(v, "spawn "); ok {
274- add("binds."+k, "invalid action")274+ if len(strings.Fields(rest)) == 0 {
275+ add("binds."+k, "spawn needs a command")
276+ }
277+ } else if !actions[v] {
278+ add("binds."+k, `invalid action (or "spawn <command>")`)
275 }279 }
276 }280 }
277 return errors.Join(errs...)281 return errors.Join(errs...)
M internal/adapters/config/config_test.go +15 -1
@@ -13,7 +13,7 @@ func TestDefaultsAndLoad(t *testing.T) {
1313 if err := Validate(d); err != nil {
1414 t.Fatal(err)
1515 }
16- if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 11 {
16+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 12 {
1717 t.Fatalf("defaults: %+v", d)
1818 }
1919 path := filepath.Join(t.TempDir(), "missing")
@@ -113,3 +113,17 @@ func TestOutputs(t *testing.T) {
113113 t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
114114 }
115115 }
116+
117+func TestSpawnBind(t *testing.T) {
118+ c := Defaults()
119+ c.Binds = map[string]string{"Cmd+D": "spawn fuzzel --prompt >"}
120+ if err := Validate(c); err != nil {
121+ t.Fatal(err)
122+ }
123+ for _, bad := range []string{"spawn", "spawn ", "spawnfuzzel"} {
124+ c.Binds = map[string]string{"Cmd+D": bad}
125+ if Validate(c) == nil {
126+ t.Errorf("accepted %q", bad)
127+ }
128+ }
129+}
@@ -13,7 +13,7 @@ func TestDefaultsAndLoad(t *testing.T) {
13 if err := Validate(d); err != nil {13 if err := Validate(d); err != nil {
14 t.Fatal(err)14 t.Fatal(err)
15 }15 }
16- if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 11 {16+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 12 {
17 t.Fatalf("defaults: %+v", d)17 t.Fatalf("defaults: %+v", d)
18 }18 }
19 path := filepath.Join(t.TempDir(), "missing")19 path := filepath.Join(t.TempDir(), "missing")
@@ -113,3 +113,17 @@ func TestOutputs(t *testing.T) {
113 t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)113 t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
114 }114 }
115 }115 }
116+
117+func TestSpawnBind(t *testing.T) {
118+ c := Defaults()
119+ c.Binds = map[string]string{"Cmd+D": "spawn fuzzel --prompt >"}
120+ if err := Validate(c); err != nil {
121+ t.Fatal(err)
122+ }
123+ for _, bad := range []string{"spawn", "spawn ", "spawnfuzzel"} {
124+ c.Binds = map[string]string{"Cmd+D": bad}
125+ if Validate(c) == nil {
126+ t.Errorf("accepted %q", bad)
127+ }
128+ }
129+}
M internal/adapters/wayland/globals.go +2 -2
@@ -302,9 +302,9 @@ func (h seat) GetKeyboard(r *wayland.Seat, id uint32) {
302302 if r.Version() >= 4 {
303303 k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
304304 }
305- if w := s.windows[s.focused]; w != nil && w.mapped && w.xdg.resource.Client() == r.Client() {
305+ if surf, _ := s.focusTarget(s.focused); surf != nil && surf.Client() == r.Client() {
306306 s.serial++
307- k.SendEnter(s.serial, w.xdg.surfaceResource(), []byte{})
307+ k.SendEnter(s.serial, surf, []byte{})
308308 s.sendModifiers(k)
309309 }
310310 }
@@ -302,9 +302,9 @@ func (h seat) GetKeyboard(r *wayland.Seat, id uint32) {
302 if r.Version() >= 4 {302 if r.Version() >= 4 {
303 k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))303 k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
304 }304 }
305- if w := s.windows[s.focused]; w != nil && w.mapped && w.xdg.resource.Client() == r.Client() {305+ if surf, _ := s.focusTarget(s.focused); surf != nil && surf.Client() == r.Client() {
306 s.serial++306 s.serial++
307- k.SendEnter(s.serial, w.xdg.surfaceResource(), []byte{})307+ k.SendEnter(s.serial, surf, []byte{})
308 s.sendModifiers(k)308 s.sendModifiers(k)
309 }309 }
310 }310 }
M internal/adapters/wayland/layer.go +4 -2
@@ -88,7 +88,6 @@ func (l *layerSurface) SetMargin(_ *wlrlayershell.ZwlrLayerSurfaceV1, t, r, b, l
8888 l.pending.margin = [4]int32{t, r, b, left}
8989 }
9090
91-// Keyboard interactivity is deferred; layer-shell is capped at v4.
9291 func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfaceV1, k uint32) {
9392 if k > 2 {
9493 r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity")
@@ -124,6 +123,9 @@ func (l *layerSurface) unmap() {
124123 if !l.mapped {
125124 return
126125 }
126+ if s := l.shell.server; s.focused == l.id {
127+ s.changeFocus(0)
128+ }
127129 l.mapped = false
128130 l.configured = false
129131 l.acked = false
@@ -137,7 +139,7 @@ func (s *Server) layerChanged() {
137139 if !l.mapped {
138140 continue
139141 }
140- v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace}
142+ v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace, Keyboard: l.current.keyboard}
141143 if l.surface.current != nil {
142144 if b := s.buffers[l.surface.current.Resource]; b != nil {
143145 v.Width, v.Height = b.width, b.height
@@ -88,7 +88,6 @@ func (l *layerSurface) SetMargin(_ *wlrlayershell.ZwlrLayerSurfaceV1, t, r, b, l
88 l.pending.margin = [4]int32{t, r, b, left}88 l.pending.margin = [4]int32{t, r, b, left}
89 }89 }
90 90
91-// Keyboard interactivity is deferred; layer-shell is capped at v4.
92 func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfaceV1, k uint32) {91 func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfaceV1, k uint32) {
93 if k > 2 {92 if k > 2 {
94 r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity")93 r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity")
@@ -124,6 +123,9 @@ func (l *layerSurface) unmap() {
124 if !l.mapped {123 if !l.mapped {
125 return124 return
126 }125 }
126+ if s := l.shell.server; s.focused == l.id {
127+ s.changeFocus(0)
128+ }
127 l.mapped = false129 l.mapped = false
128 l.configured = false130 l.configured = false
129 l.acked = false131 l.acked = false
@@ -137,7 +139,7 @@ func (s *Server) layerChanged() {
137 if !l.mapped {139 if !l.mapped {
138 continue140 continue
139 }141 }
140- v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace}142+ v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace, Keyboard: l.current.keyboard}
141 if l.surface.current != nil {143 if l.surface.current != nil {
142 if b := s.buffers[l.surface.current.Resource]; b != nil {144 if b := s.buffers[l.surface.current.Resource]; b != nil {
143 v.Width, v.Height = b.width, b.height145 v.Width, v.Height = b.width, b.height
M internal/adapters/wayland/server.go +33 -11
@@ -346,8 +346,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
346346 s.changeFocus(c.ID)
347347 }
348348 case ports.ForwardKey:
349- w := s.windows[c.ID]
350- if c.ID != s.focused || w == nil || !w.mapped || len(s.windowKeyboards(w)) == 0 {
349+ _, keyboards := s.focusTarget(c.ID)
350+ if c.ID != s.focused || len(keyboards) == 0 {
351351 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")
352352 return
353353 }
@@ -364,13 +364,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
364364 if c.Key.Pressed {
365365 state = 1
366366 }
367- for _, k := range s.windowKeyboards(w) {
367+ for _, k := range keyboards {
368368 k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state)
369369 }
370370 if c.Key.State != s.modState {
371371 s.modState = c.Key.State
372372 s.serial++
373- for _, k := range s.windowKeyboards(w) {
373+ for _, k := range keyboards {
374374 s.sendModifiers(k)
375375 }
376376 }
@@ -397,8 +397,12 @@ func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
397397 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
398398 return nil
399399 }
400+ return s.clientKeyboards(w.xdg.resource.Client())
401+}
402+
403+func (s *Server) clientKeyboards(c server.Client) []*wayland.Keyboard {
400404 var alive []*wayland.Keyboard
401- for _, k := range s.keyboards[w.xdg.resource.Client()] {
405+ for _, k := range s.keyboards[c] {
402406 if k.Resource.Alive() {
403407 alive = append(alive, k)
404408 }
@@ -406,31 +410,49 @@ func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
406410 return alive
407411 }
408412
413+// focusTarget resolves a window or layer ID to its wl_surface and keyboards.
414+func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Keyboard) {
415+ if w := s.windows[id]; w != nil && w.mapped {
416+ if surf := w.xdg.surfaceResource(); surf != nil {
417+ return surf, s.windowKeyboards(w)
418+ }
419+ }
420+ if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
421+ for resource, state := range s.surfaces {
422+ if state == l.surface && resource.Alive() {
423+ return wayland.WrapSurface(resource), s.clientKeyboards(l.resource.Client())
424+ }
425+ }
426+ }
427+ return nil, nil
428+}
429+
409430 func (s *Server) sendModifiers(k *wayland.Keyboard) {
410431 m := s.modState
411432 k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
412433 }
413434
414435 func (s *Server) changeFocus(id ports.WindowID) {
415- if old := s.windows[s.focused]; old != nil && old.xdg.surfaceResource() != nil {
416- for _, k := range s.windowKeyboards(old) {
436+ if surf, keyboards := s.focusTarget(s.focused); surf != nil {
437+ for _, k := range keyboards {
417438 s.serial++
418- k.SendLeave(s.serial, old.xdg.surfaceResource())
439+ k.SendLeave(s.serial, surf)
419440 }
420441 }
421442 s.focused = 0
422- if w := s.windows[id]; w != nil && w.mapped && w.xdg.surfaceResource() != nil {
443+ if surf, keyboards := s.focusTarget(id); surf != nil {
423444 s.focused = id
424- for _, k := range s.windowKeyboards(w) {
445+ for _, k := range keyboards {
425446 s.serial++
426447 var keys []byte
427448 for code := range s.heldKeys {
428449 keys = binary.LittleEndian.AppendUint32(keys, code)
429450 }
430- k.SendEnter(s.serial, w.xdg.surfaceResource(), keys)
451+ k.SendEnter(s.serial, surf, keys)
431452 s.sendModifiers(k)
432453 }
433454 }
455+ s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")
434456 }
435457
436458 func pointerFrame(p *wayland.Pointer) {
@@ -346,8 +346,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
346 s.changeFocus(c.ID)346 s.changeFocus(c.ID)
347 }347 }
348 case ports.ForwardKey:348 case ports.ForwardKey:
349- w := s.windows[c.ID]349+ _, keyboards := s.focusTarget(c.ID)
350- if c.ID != s.focused || w == nil || !w.mapped || len(s.windowKeyboards(w)) == 0 {350+ if c.ID != s.focused || len(keyboards) == 0 {
351 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")351 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")
352 return352 return
353 }353 }
@@ -364,13 +364,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
364 if c.Key.Pressed {364 if c.Key.Pressed {
365 state = 1365 state = 1
366 }366 }
367- for _, k := range s.windowKeyboards(w) {367+ for _, k := range keyboards {
368 k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state)368 k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state)
369 }369 }
370 if c.Key.State != s.modState {370 if c.Key.State != s.modState {
371 s.modState = c.Key.State371 s.modState = c.Key.State
372 s.serial++372 s.serial++
373- for _, k := range s.windowKeyboards(w) {373+ for _, k := range keyboards {
374 s.sendModifiers(k)374 s.sendModifiers(k)
375 }375 }
376 }376 }
@@ -397,8 +397,12 @@ func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
397 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {397 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
398 return nil398 return nil
399 }399 }
400+ return s.clientKeyboards(w.xdg.resource.Client())
401+}
402+
403+func (s *Server) clientKeyboards(c server.Client) []*wayland.Keyboard {
400 var alive []*wayland.Keyboard404 var alive []*wayland.Keyboard
401- for _, k := range s.keyboards[w.xdg.resource.Client()] {405+ for _, k := range s.keyboards[c] {
402 if k.Resource.Alive() {406 if k.Resource.Alive() {
403 alive = append(alive, k)407 alive = append(alive, k)
404 }408 }
@@ -406,31 +410,49 @@ func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
406 return alive410 return alive
407 }411 }
408 412
413+// focusTarget resolves a window or layer ID to its wl_surface and keyboards.
414+func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Keyboard) {
415+ if w := s.windows[id]; w != nil && w.mapped {
416+ if surf := w.xdg.surfaceResource(); surf != nil {
417+ return surf, s.windowKeyboards(w)
418+ }
419+ }
420+ if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
421+ for resource, state := range s.surfaces {
422+ if state == l.surface && resource.Alive() {
423+ return wayland.WrapSurface(resource), s.clientKeyboards(l.resource.Client())
424+ }
425+ }
426+ }
427+ return nil, nil
428+}
429+
409 func (s *Server) sendModifiers(k *wayland.Keyboard) {430 func (s *Server) sendModifiers(k *wayland.Keyboard) {
410 m := s.modState431 m := s.modState
411 k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)432 k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
412 }433 }
413 434
414 func (s *Server) changeFocus(id ports.WindowID) {435 func (s *Server) changeFocus(id ports.WindowID) {
415- if old := s.windows[s.focused]; old != nil && old.xdg.surfaceResource() != nil {436+ if surf, keyboards := s.focusTarget(s.focused); surf != nil {
416- for _, k := range s.windowKeyboards(old) {437+ for _, k := range keyboards {
417 s.serial++438 s.serial++
418- k.SendLeave(s.serial, old.xdg.surfaceResource())439+ k.SendLeave(s.serial, surf)
419 }440 }
420 }441 }
421 s.focused = 0442 s.focused = 0
422- if w := s.windows[id]; w != nil && w.mapped && w.xdg.surfaceResource() != nil {443+ if surf, keyboards := s.focusTarget(id); surf != nil {
423 s.focused = id444 s.focused = id
424- for _, k := range s.windowKeyboards(w) {445+ for _, k := range keyboards {
425 s.serial++446 s.serial++
426 var keys []byte447 var keys []byte
427 for code := range s.heldKeys {448 for code := range s.heldKeys {
428 keys = binary.LittleEndian.AppendUint32(keys, code)449 keys = binary.LittleEndian.AppendUint32(keys, code)
429 }450 }
430- k.SendEnter(s.serial, w.xdg.surfaceResource(), keys)451+ k.SendEnter(s.serial, surf, keys)
431 s.sendModifiers(k)452 s.sendModifiers(k)
432 }453 }
433 }454 }
455+ s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")
434 }456 }
435 457
436 func pointerFrame(p *wayland.Pointer) {458 func pointerFrame(p *wayland.Pointer) {
M internal/core/action.go +17 -0
@@ -1,5 +1,7 @@
11 package core
22
3+import "strings"
4+
35 type Action string
46
57 const (
@@ -16,13 +18,28 @@ const (
1618 ActionQuit Action = "quit"
1719 )
1820
21+// spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag".
22+// Arguments are split on whitespace; there is no shell (use "spawn sh -c ...").
23+const spawnPrefix = "spawn "
24+
25+// SpawnArgv returns the command of a "spawn <cmd>" action.
26+func SpawnArgv(a Action) ([]string, bool) {
27+ rest, ok := strings.CutPrefix(string(a), spawnPrefix)
28+ argv := strings.Fields(rest)
29+ return argv, ok && len(argv) > 0
30+}
31+
1932 type Effect struct {
2033 Spawn bool
34+ Argv []string // command to run; nil means the configured terminal
2135 Close WindowID
2236 Quit bool
2337 }
2438
2539 func (w *Workspace) Apply(a Action) Effect {
40+ if argv, ok := SpawnArgv(a); ok {
41+ return Effect{Spawn: true, Argv: argv}
42+ }
2643 switch a {
2744 case ActionSpawnTerminal:
2845 return Effect{Spawn: true}
@@ -1,5 +1,7 @@
1 package core1 package core
2 2
3+import "strings"
4+
3 type Action string5 type Action string
4 6
5 const (7 const (
@@ -16,13 +18,28 @@ const (
16 ActionQuit Action = "quit"18 ActionQuit Action = "quit"
17 )19 )
18 20
21+// spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag".
22+// Arguments are split on whitespace; there is no shell (use "spawn sh -c ...").
23+const spawnPrefix = "spawn "
24+
25+// SpawnArgv returns the command of a "spawn <cmd>" action.
26+func SpawnArgv(a Action) ([]string, bool) {
27+ rest, ok := strings.CutPrefix(string(a), spawnPrefix)
28+ argv := strings.Fields(rest)
29+ return argv, ok && len(argv) > 0
30+}
31+
19 type Effect struct {32 type Effect struct {
20 Spawn bool33 Spawn bool
34+ Argv []string // command to run; nil means the configured terminal
21 Close WindowID35 Close WindowID
22 Quit bool36 Quit bool
23 }37 }
24 38
25 func (w *Workspace) Apply(a Action) Effect {39 func (w *Workspace) Apply(a Action) Effect {
40+ if argv, ok := SpawnArgv(a); ok {
41+ return Effect{Spawn: true, Argv: argv}
42+ }
26 switch a {43 switch a {
27 case ActionSpawnTerminal:44 case ActionSpawnTerminal:
28 return Effect{Spawn: true}45 return Effect{Spawn: true}
M internal/core/core.go +28 -3
@@ -113,6 +113,10 @@ func (c *Core) apply(cfg ports.Config) error {
113113 if _, ok := binds[b]; ok {
114114 return fmt.Errorf("duplicate bind %q", combo)
115115 }
116+ if _, ok := SpawnArgv(Action(a)); ok {
117+ binds[b] = Action(a)
118+ continue
119+ }
116120 switch Action(a) {
117121 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit:
118122 default:
@@ -157,11 +161,28 @@ func (c *Core) clientRect(p Placement) Rect {
157161 return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
158162 }
159163
164+// keyboardFocus is the mapped top/overlay layer with exclusive keyboard
165+// interactivity and the highest ID, else the focused window. On-demand layers
166+// never take focus automatically. When the layer unmaps, focus returns to the window.
167+func (c *Core) keyboardFocus() WindowID {
168+ var layer WindowID
169+ for _, l := range c.layers {
170+ if l.Keyboard == 1 && (l.Layer == ports.LayerTop || l.Layer == ports.LayerOverlay) && l.ID > layer {
171+ layer = l.ID
172+ }
173+ }
174+ if layer != 0 {
175+ return layer
176+ }
177+ id, _ := c.ws.Focused()
178+ return id
179+}
180+
160181 func (c *Core) publish(ctx context.Context) error {
161182 c.seq++
162183 scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
163184 alive := map[WindowID]bool{}
164- focus, _ := c.ws.Focused()
185+ focus := c.keyboardFocus()
165186 for _, p := range c.ws.Layout() {
166187 alive[p.ID] = true
167188 scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden})
@@ -364,10 +385,14 @@ func (c *Core) Run(ctx context.Context) error {
364385 return ErrQuit
365386 }
366387 if effect.Spawn {
388+ argv := effect.Argv
389+ if argv == nil {
390+ argv = append([]string(nil), c.cfg.Terminal.Command...)
391+ }
367392 select {
368393 case <-ctx.Done():
369394 return nil
370- case c.ch.Spawn <- ports.SpawnRequest{Argv: append([]string(nil), c.cfg.Terminal.Command...)}:
395+ case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
371396 }
372397 }
373398 if effect.Close != 0 {
@@ -382,7 +407,7 @@ func (c *Core) Run(ctx context.Context) error {
382407 }
383408 c.pressed[name] = false
384409 }
385- if id, ok := c.ws.Focused(); ok {
410+ if id := c.keyboardFocus(); id != 0 {
386411 if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil {
387412 return nil
388413 }
@@ -113,6 +113,10 @@ func (c *Core) apply(cfg ports.Config) error {
113 if _, ok := binds[b]; ok {113 if _, ok := binds[b]; ok {
114 return fmt.Errorf("duplicate bind %q", combo)114 return fmt.Errorf("duplicate bind %q", combo)
115 }115 }
116+ if _, ok := SpawnArgv(Action(a)); ok {
117+ binds[b] = Action(a)
118+ continue
119+ }
116 switch Action(a) {120 switch Action(a) {
117 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit:121 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit:
118 default:122 default:
@@ -157,11 +161,28 @@ func (c *Core) clientRect(p Placement) Rect {
157 return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}161 return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
158 }162 }
159 163
164+// keyboardFocus is the mapped top/overlay layer with exclusive keyboard
165+// interactivity and the highest ID, else the focused window. On-demand layers
166+// never take focus automatically. When the layer unmaps, focus returns to the window.
167+func (c *Core) keyboardFocus() WindowID {
168+ var layer WindowID
169+ for _, l := range c.layers {
170+ if l.Keyboard == 1 && (l.Layer == ports.LayerTop || l.Layer == ports.LayerOverlay) && l.ID > layer {
171+ layer = l.ID
172+ }
173+ }
174+ if layer != 0 {
175+ return layer
176+ }
177+ id, _ := c.ws.Focused()
178+ return id
179+}
180+
160 func (c *Core) publish(ctx context.Context) error {181 func (c *Core) publish(ctx context.Context) error {
161 c.seq++182 c.seq++
162 scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}183 scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
163 alive := map[WindowID]bool{}184 alive := map[WindowID]bool{}
164- focus, _ := c.ws.Focused()185+ focus := c.keyboardFocus()
165 for _, p := range c.ws.Layout() {186 for _, p := range c.ws.Layout() {
166 alive[p.ID] = true187 alive[p.ID] = true
167 scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden})188 scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden})
@@ -364,10 +385,14 @@ func (c *Core) Run(ctx context.Context) error {
364 return ErrQuit385 return ErrQuit
365 }386 }
366 if effect.Spawn {387 if effect.Spawn {
388+ argv := effect.Argv
389+ if argv == nil {
390+ argv = append([]string(nil), c.cfg.Terminal.Command...)
391+ }
367 select {392 select {
368 case <-ctx.Done():393 case <-ctx.Done():
369 return nil394 return nil
370- case c.ch.Spawn <- ports.SpawnRequest{Argv: append([]string(nil), c.cfg.Terminal.Command...)}:395+ case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
371 }396 }
372 }397 }
373 if effect.Close != 0 {398 if effect.Close != 0 {
@@ -382,7 +407,7 @@ func (c *Core) Run(ctx context.Context) error {
382 }407 }
383 c.pressed[name] = false408 c.pressed[name] = false
384 }409 }
385- if id, ok := c.ws.Focused(); ok {410+ if id := c.keyboardFocus(); id != 0 {
386 if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil {411 if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil {
387 return nil412 return nil
388 }413 }
M internal/core/core_test.go +53 -0
@@ -282,3 +282,56 @@ func TestBorderInset(t *testing.T) {
282282 t.Fatal(v)
283283 }
284284 }
285+
286+func TestLayerKeyboardFocus(t *testing.T) {
287+ cfg := config.Defaults()
288+ client := make(chan ports.ClientEvent, 8)
289+ input := make(chan ports.InputEvent, 8)
290+ output := make(chan ports.OutputEvent, 8)
291+ commands := make(chan ports.ClientCommand, 64)
292+ scenes := make(chan ports.Scene, 1)
293+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
294+ if err != nil {
295+ t.Fatal(err)
296+ }
297+ ctx, cancel := context.WithCancel(context.Background())
298+ defer cancel()
299+ go c.Run(ctx)
300+ output <- ports.OutputMode{Width: 100, Height: 80}
301+ receive(t, scenes)
302+ client <- ports.WindowMapped{ID: 1}
303+ receive(t, scenes)
304+ focusOf := func() ports.WindowID {
305+ t.Helper()
306+ for {
307+ if v, ok := receive(t, commands).(ports.FocusWindow); ok {
308+ return v.ID
309+ }
310+ }
311+ }
312+ if id := focusOf(); id != 1 {
313+ t.Fatalf("window focus %d", id)
314+ }
315+ // An on-demand bar does not steal focus; an exclusive launcher does.
316+ launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 40, Height: 20, Keyboard: 1}
317+ bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 5, Keyboard: 2}
318+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}}
319+ receive(t, scenes)
320+ if id := focusOf(); id != 7 {
321+ t.Fatalf("layer focus %d", id)
322+ }
323+ input <- ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}
324+ for {
325+ if v, ok := receive(t, commands).(ports.ForwardKey); ok {
326+ if v.ID != 7 {
327+ t.Fatalf("key went to %d", v.ID)
328+ }
329+ break
330+ }
331+ }
332+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
333+ receive(t, scenes)
334+ if id := focusOf(); id != 1 {
335+ t.Fatalf("focus not restored: %d", id)
336+ }
337+}
@@ -282,3 +282,56 @@ func TestBorderInset(t *testing.T) {
282 t.Fatal(v)282 t.Fatal(v)
283 }283 }
284 }284 }
285+
286+func TestLayerKeyboardFocus(t *testing.T) {
287+ cfg := config.Defaults()
288+ client := make(chan ports.ClientEvent, 8)
289+ input := make(chan ports.InputEvent, 8)
290+ output := make(chan ports.OutputEvent, 8)
291+ commands := make(chan ports.ClientCommand, 64)
292+ scenes := make(chan ports.Scene, 1)
293+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
294+ if err != nil {
295+ t.Fatal(err)
296+ }
297+ ctx, cancel := context.WithCancel(context.Background())
298+ defer cancel()
299+ go c.Run(ctx)
300+ output <- ports.OutputMode{Width: 100, Height: 80}
301+ receive(t, scenes)
302+ client <- ports.WindowMapped{ID: 1}
303+ receive(t, scenes)
304+ focusOf := func() ports.WindowID {
305+ t.Helper()
306+ for {
307+ if v, ok := receive(t, commands).(ports.FocusWindow); ok {
308+ return v.ID
309+ }
310+ }
311+ }
312+ if id := focusOf(); id != 1 {
313+ t.Fatalf("window focus %d", id)
314+ }
315+ // An on-demand bar does not steal focus; an exclusive launcher does.
316+ launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 40, Height: 20, Keyboard: 1}
317+ bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 5, Keyboard: 2}
318+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}}
319+ receive(t, scenes)
320+ if id := focusOf(); id != 7 {
321+ t.Fatalf("layer focus %d", id)
322+ }
323+ input <- ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}
324+ for {
325+ if v, ok := receive(t, commands).(ports.ForwardKey); ok {
326+ if v.ID != 7 {
327+ t.Fatalf("key went to %d", v.ID)
328+ }
329+ break
330+ }
331+ }
332+ client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
333+ receive(t, scenes)
334+ if id := focusOf(); id != 1 {
335+ t.Fatalf("focus not restored: %d", id)
336+ }
337+}
M internal/core/layout_test.go +3 -2
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "math"
5+ "reflect"
56 "testing"
67 )
78
@@ -124,8 +125,8 @@ func TestPresetsAndActions(t *testing.T) {
124125 for _, tc := range []struct {
125126 a Action
126127 want Effect
127- }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}} {
128- if got := w.Apply(tc.a); got != tc.want {
128+ }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}, {"spawn fuzzel --prompt x", Effect{Spawn: true, Argv: []string{"fuzzel", "--prompt", "x"}}}, {"spawn ", Effect{}}} {
129+ if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) {
129130 t.Errorf("%s: %+v", tc.a, got)
130131 }
131132 }
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "math"4 "math"
5+ "reflect"
5 "testing"6 "testing"
6 )7 )
7 8
@@ -124,8 +125,8 @@ func TestPresetsAndActions(t *testing.T) {
124 for _, tc := range []struct {125 for _, tc := range []struct {
125 a Action126 a Action
126 want Effect127 want Effect
127- }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}} {128+ }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}, {"spawn fuzzel --prompt x", Effect{Spawn: true, Argv: []string{"fuzzel", "--prompt", "x"}}}, {"spawn ", Effect{}}} {
128- if got := w.Apply(tc.a); got != tc.want {129+ if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) {
129 t.Errorf("%s: %+v", tc.a, got)130 t.Errorf("%s: %+v", tc.a, got)
130 }131 }
131 }132 }
M internal/ports/messages.go +2 -0
@@ -53,6 +53,8 @@ type LayerSurface struct {
5353 Margin [4]int32
5454 Width, Height int
5555 Namespace string
56+ // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand.
57+ Keyboard uint32
5658 }
5759
5860 // LayerChanged carries wayland → core the full list of mapped layer surfaces,
@@ -53,6 +53,8 @@ type LayerSurface struct {
53 Margin [4]int3253 Margin [4]int32
54 Width, Height int54 Width, Height int
55 Namespace string55 Namespace string
56+ // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand.
57+ Keyboard uint32
56 }58 }
57 59
58 // LayerChanged carries wayland → core the full list of mapped layer surfaces,60 // LayerChanged carries wayland → core the full list of mapped layer surfaces,

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