merge: launcher (spawn bind action, layer keyboard focus)
11 files · +166 -24
M examples/config.toml +2 -0
| @@ -54,3 +54,5 @@ debug = [] | |||
| 54 | 54 | "Cmd+Shift+F" = "toggle-fullscreen" | |
| 55 | 55 | "Cmd+Q" = "close-window" | |
| 56 | 56 | "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 ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 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 | 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 | 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 | 270 | combos[n] = k | |
| 271 | 271 | } | |
| 272 | 272 | } | |
| 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>")`) | |
| 275 | 279 | } | |
| 276 | 280 | } | |
| 277 | 281 | 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] = k | 270 | 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) { | |||
| 13 | 13 | if err := Validate(d); err != nil { | |
| 14 | 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 | 17 | t.Fatalf("defaults: %+v", d) | |
| 18 | 18 | } | |
| 19 | 19 | path := filepath.Join(t.TempDir(), "missing") | |
| @@ -113,3 +113,17 @@ func TestOutputs(t *testing.T) { | |||
| 113 | 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 | + | } | |
| @@ -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) { | |||
| 302 | 302 | if r.Version() >= 4 { | |
| 303 | 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 | 306 | s.serial++ | |
| 307 | - | k.SendEnter(s.serial, w.xdg.surfaceResource(), []byte{}) | |
| 307 | + | k.SendEnter(s.serial, surf, []byte{}) | |
| 308 | 308 | s.sendModifiers(k) | |
| 309 | 309 | } | |
| 310 | 310 | } | |
| @@ -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 | |||
| 88 | 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 | 91 | func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfaceV1, k uint32) { | |
| 93 | 92 | if k > 2 { | |
| 94 | 93 | r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity") | |
| @@ -124,6 +123,9 @@ func (l *layerSurface) unmap() { | |||
| 124 | 123 | if !l.mapped { | |
| 125 | 124 | return | |
| 126 | 125 | } | |
| 126 | + | if s := l.shell.server; s.focused == l.id { | |
| 127 | + | s.changeFocus(0) | |
| 128 | + | } | |
| 127 | 129 | l.mapped = false | |
| 128 | 130 | l.configured = false | |
| 129 | 131 | l.acked = false | |
| @@ -137,7 +139,7 @@ func (s *Server) layerChanged() { | |||
| 137 | 139 | if !l.mapped { | |
| 138 | 140 | 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 | 143 | if l.surface.current != nil { | |
| 142 | 144 | if b := s.buffers[l.surface.current.Resource]; b != nil { | |
| 143 | 145 | 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 | return | 124 | return | ||
| 126 | } | 125 | } | ||
| 126 | + | if s := l.shell.server; s.focused == l.id { | |||
| 127 | + | s.changeFocus(0) | |||
| 128 | + | } | |||
| 127 | l.mapped = false | 129 | l.mapped = false | ||
| 128 | l.configured = false | 130 | l.configured = false | ||
| 129 | l.acked = false | 131 | l.acked = false | ||
| @@ -137,7 +139,7 @@ func (s *Server) layerChanged() { | |||||
| 137 | if !l.mapped { | 139 | if !l.mapped { | ||
| 138 | continue | 140 | 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.height | 145 | 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) { | |||
| 346 | 346 | s.changeFocus(c.ID) | |
| 347 | 347 | } | |
| 348 | 348 | 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 { | |
| 351 | 351 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key") | |
| 352 | 352 | return | |
| 353 | 353 | } | |
| @@ -364,13 +364,13 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 364 | 364 | if c.Key.Pressed { | |
| 365 | 365 | state = 1 | |
| 366 | 366 | } | |
| 367 | - | for _, k := range s.windowKeyboards(w) { | |
| 367 | + | for _, k := range keyboards { | |
| 368 | 368 | k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state) | |
| 369 | 369 | } | |
| 370 | 370 | if c.Key.State != s.modState { | |
| 371 | 371 | s.modState = c.Key.State | |
| 372 | 372 | s.serial++ | |
| 373 | - | for _, k := range s.windowKeyboards(w) { | |
| 373 | + | for _, k := range keyboards { | |
| 374 | 374 | s.sendModifiers(k) | |
| 375 | 375 | } | |
| 376 | 376 | } | |
| @@ -397,8 +397,12 @@ func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard { | |||
| 397 | 397 | if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() { | |
| 398 | 398 | 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 | 404 | var alive []*wayland.Keyboard | |
| 401 | - | for _, k := range s.keyboards[w.xdg.resource.Client()] { | |
| 405 | + | for _, k := range s.keyboards[c] { | |
| 402 | 406 | if k.Resource.Alive() { | |
| 403 | 407 | alive = append(alive, k) | |
| 404 | 408 | } | |
| @@ -406,31 +410,49 @@ func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard { | |||
| 406 | 410 | 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 | 430 | func (s *Server) sendModifiers(k *wayland.Keyboard) { | |
| 410 | 431 | m := s.modState | |
| 411 | 432 | k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group) | |
| 412 | 433 | } | |
| 413 | 434 | ||
| 414 | 435 | 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 { | |
| 417 | 438 | s.serial++ | |
| 418 | - | k.SendLeave(s.serial, old.xdg.surfaceResource()) | |
| 439 | + | k.SendLeave(s.serial, surf) | |
| 419 | 440 | } | |
| 420 | 441 | } | |
| 421 | 442 | 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 | 444 | s.focused = id | |
| 424 | - | for _, k := range s.windowKeyboards(w) { | |
| 445 | + | for _, k := range keyboards { | |
| 425 | 446 | s.serial++ | |
| 426 | 447 | var keys []byte | |
| 427 | 448 | for code := range s.heldKeys { | |
| 428 | 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 | 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 | 458 | 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 | return | 352 | 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 = 1 | 365 | 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.State | 371 | 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 nil | 398 | 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.Keyboard | 404 | 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 alive | 410 | 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.modState | 431 | 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 = 0 | 442 | 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 = id | 444 | s.focused = id | ||
| 424 | - | for _, k := range s.windowKeyboards(w) { | 445 | + | for _, k := range keyboards { |
| 425 | s.serial++ | 446 | s.serial++ | ||
| 426 | var keys []byte | 447 | 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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 3 | 5 | type Action string | |
| 4 | 6 | ||
| 5 | 7 | const ( | |
| @@ -16,13 +18,28 @@ const ( | |||
| 16 | 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 | 32 | type Effect struct { | |
| 20 | 33 | Spawn bool | |
| 34 | + | Argv []string // command to run; nil means the configured terminal | |
| 21 | 35 | Close WindowID | |
| 22 | 36 | Quit bool | |
| 23 | 37 | } | |
| 24 | 38 | ||
| 25 | 39 | func (w *Workspace) Apply(a Action) Effect { | |
| 40 | + | if argv, ok := SpawnArgv(a); ok { | |
| 41 | + | return Effect{Spawn: true, Argv: argv} | |
| 42 | + | } | |
| 26 | 43 | switch a { | |
| 27 | 44 | case ActionSpawnTerminal: | |
| 28 | 45 | return Effect{Spawn: true} | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 3 | type Action string | 5 | 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 bool | 33 | Spawn bool | ||
| 34 | + | Argv []string // command to run; nil means the configured terminal | |||
| 21 | Close WindowID | 35 | Close WindowID | ||
| 22 | Quit bool | 36 | 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 { | |||
| 113 | 113 | if _, ok := binds[b]; ok { | |
| 114 | 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 | 120 | switch Action(a) { | |
| 117 | 121 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit: | |
| 118 | 122 | default: | |
| @@ -157,11 +161,28 @@ func (c *Core) clientRect(p Placement) Rect { | |||
| 157 | 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 | 181 | func (c *Core) publish(ctx context.Context) error { | |
| 161 | 182 | c.seq++ | |
| 162 | 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 | 184 | alive := map[WindowID]bool{} | |
| 164 | - | focus, _ := c.ws.Focused() | |
| 185 | + | focus := c.keyboardFocus() | |
| 165 | 186 | for _, p := range c.ws.Layout() { | |
| 166 | 187 | alive[p.ID] = true | |
| 167 | 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 | 385 | return ErrQuit | |
| 365 | 386 | } | |
| 366 | 387 | if effect.Spawn { | |
| 388 | + | argv := effect.Argv | |
| 389 | + | if argv == nil { | |
| 390 | + | argv = append([]string(nil), c.cfg.Terminal.Command...) | |
| 391 | + | } | |
| 367 | 392 | select { | |
| 368 | 393 | case <-ctx.Done(): | |
| 369 | 394 | 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 | 398 | if effect.Close != 0 { | |
| @@ -382,7 +407,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 382 | 407 | } | |
| 383 | 408 | c.pressed[name] = false | |
| 384 | 409 | } | |
| 385 | - | if id, ok := c.ws.Focused(); ok { | |
| 410 | + | if id := c.keyboardFocus(); id != 0 { | |
| 386 | 411 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | |
| 387 | 412 | return nil | |
| 388 | 413 | } | |
| @@ -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] = true | 187 | 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 ErrQuit | 385 | 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 nil | 394 | 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] = false | 408 | 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 nil | 412 | return nil | ||
| 388 | } | 413 | } | ||
M internal/core/core_test.go +53 -0
| @@ -282,3 +282,56 @@ func TestBorderInset(t *testing.T) { | |||
| 282 | 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 | + | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | + | "reflect" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| @@ -124,8 +125,8 @@ func TestPresetsAndActions(t *testing.T) { | |||
| 124 | 125 | for _, tc := range []struct { | |
| 125 | 126 | a Action | |
| 126 | 127 | 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) { | |
| 129 | 130 | t.Errorf("%s: %+v", tc.a, got) | |
| 130 | 131 | } | |
| 131 | 132 | } | |
| @@ -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 Action | 126 | a Action | ||
| 126 | want Effect | 127 | 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 { | |||
| 53 | 53 | Margin [4]int32 | |
| 54 | 54 | Width, Height int | |
| 55 | 55 | Namespace string | |
| 56 | + | // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand. | |
| 57 | + | Keyboard uint32 | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | 60 | // LayerChanged carries wayland → core the full list of mapped layer surfaces, | |
| @@ -53,6 +53,8 @@ type LayerSurface struct { | |||||
| 53 | Margin [4]int32 | 53 | Margin [4]int32 | ||
| 54 | Width, Height int | 54 | Width, Height int | ||
| 55 | Namespace string | 55 | 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, | ||