feat(input): add pointer focus, click-to-focus and scripted pointer input
- commit
- 5cf91344604cc454f37767f7aa7849fdb1a04929
- author
- brice · · 2026-09-23 16:01 UTC
- parent
- 5928c82
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +417 -16
M README.md +3 -1
| @@ -28,4 +28,6 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until | |||
| 28 | 28 | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | |
| 29 | 29 | `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame. | |
| 30 | 30 | Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is | |
| 31 | - | `type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), or `sleep 1s`. | |
| 31 | + | `type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), `sleep 1s`, `move x y` (absolute output coordinates), `click [left|right|middle]`, | |
| 32 | + | `down button`, or `up button`. Cursor rendering is not implemented yet; pointer | |
| 33 | + | focus after layout changes is updated on the next move. | |
| @@ -28,4 +28,6 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until | |||||
| 28 | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | 28 | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | ||
| 29 | `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame. | 29 | `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame. | ||
| 30 | Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is | 30 | Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is | ||
| 31 | - | `type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), or `sleep 1s`. | 31 | + | `type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), `sleep 1s`, `move x y` (absolute output coordinates), `click [left|right|middle]`, |
| 32 | + | `down button`, or `up button`. Cursor rendering is not implemented yet; pointer | |||
| 33 | + | focus after layout changes is updated on the next move. | |||
M internal/adapters/headlessinput/input.go +59 -0
| @@ -3,6 +3,8 @@ package headlessinput | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "math" | |
| 7 | + | "strconv" | |
| 6 | 8 | "strings" | |
| 7 | 9 | "time" | |
| 8 | 10 | "unicode" | |
| @@ -67,6 +69,63 @@ func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<- | |||
| 67 | 69 | if !ok { | |
| 68 | 70 | return nil | |
| 69 | 71 | } | |
| 72 | + | fields := strings.Fields(line) | |
| 73 | + | sendPointer := func(ev ports.InputEvent) error { | |
| 74 | + | select { | |
| 75 | + | case <-ctx.Done(): | |
| 76 | + | return ctx.Err() | |
| 77 | + | case input <- ev: | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | buttonCode := func(name string) uint32 { | |
| 82 | + | switch name { | |
| 83 | + | case "left": | |
| 84 | + | return 0x110 | |
| 85 | + | case "right": | |
| 86 | + | return 0x111 | |
| 87 | + | case "middle": | |
| 88 | + | return 0x112 | |
| 89 | + | } | |
| 90 | + | return 0 | |
| 91 | + | } | |
| 92 | + | now := uint32(time.Now().UnixMilli()) | |
| 93 | + | if len(fields) > 0 && fields[0] == "move" { | |
| 94 | + | if len(fields) == 3 { | |
| 95 | + | x, ex := strconv.ParseFloat(fields[1], 64) | |
| 96 | + | y, ey := strconv.ParseFloat(fields[2], 64) | |
| 97 | + | if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) { | |
| 98 | + | if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | continue | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | log.Warn().Str("line", line).Msg("invalid move") | |
| 105 | + | continue | |
| 106 | + | } | |
| 107 | + | if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") { | |
| 108 | + | name := "left" | |
| 109 | + | if len(fields) == 2 { | |
| 110 | + | name = fields[1] | |
| 111 | + | } | |
| 112 | + | code := buttonCode(name) | |
| 113 | + | if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 { | |
| 114 | + | log.Warn().Str("line", line).Msg("invalid button") | |
| 115 | + | continue | |
| 116 | + | } | |
| 117 | + | if fields[0] != "up" { | |
| 118 | + | if err := sendPointer(ports.PointerButton{Button: code, Pressed: true, TimeMsec: now}); err != nil { | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | if fields[0] != "down" { | |
| 123 | + | if err := sendPointer(ports.PointerButton{Button: code, TimeMsec: now}); err != nil { | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | continue | |
| 128 | + | } | |
| 70 | 129 | switch { | |
| 71 | 130 | case strings.HasPrefix(line, "type "): | |
| 72 | 131 | for _, r := range strings.TrimPrefix(line, "type ") { | |
| @@ -3,6 +3,8 @@ package headlessinput | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "math" | |||
| 7 | + | "strconv" | |||
| 6 | "strings" | 8 | "strings" | ||
| 7 | "time" | 9 | "time" | ||
| 8 | "unicode" | 10 | "unicode" | ||
| @@ -67,6 +69,63 @@ func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<- | |||||
| 67 | if !ok { | 69 | if !ok { | ||
| 68 | return nil | 70 | return nil | ||
| 69 | } | 71 | } | ||
| 72 | + | fields := strings.Fields(line) | |||
| 73 | + | sendPointer := func(ev ports.InputEvent) error { | |||
| 74 | + | select { | |||
| 75 | + | case <-ctx.Done(): | |||
| 76 | + | return ctx.Err() | |||
| 77 | + | case input <- ev: | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | buttonCode := func(name string) uint32 { | |||
| 82 | + | switch name { | |||
| 83 | + | case "left": | |||
| 84 | + | return 0x110 | |||
| 85 | + | case "right": | |||
| 86 | + | return 0x111 | |||
| 87 | + | case "middle": | |||
| 88 | + | return 0x112 | |||
| 89 | + | } | |||
| 90 | + | return 0 | |||
| 91 | + | } | |||
| 92 | + | now := uint32(time.Now().UnixMilli()) | |||
| 93 | + | if len(fields) > 0 && fields[0] == "move" { | |||
| 94 | + | if len(fields) == 3 { | |||
| 95 | + | x, ex := strconv.ParseFloat(fields[1], 64) | |||
| 96 | + | y, ey := strconv.ParseFloat(fields[2], 64) | |||
| 97 | + | if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) { | |||
| 98 | + | if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 101 | + | continue | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | log.Warn().Str("line", line).Msg("invalid move") | |||
| 105 | + | continue | |||
| 106 | + | } | |||
| 107 | + | if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") { | |||
| 108 | + | name := "left" | |||
| 109 | + | if len(fields) == 2 { | |||
| 110 | + | name = fields[1] | |||
| 111 | + | } | |||
| 112 | + | code := buttonCode(name) | |||
| 113 | + | if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 { | |||
| 114 | + | log.Warn().Str("line", line).Msg("invalid button") | |||
| 115 | + | continue | |||
| 116 | + | } | |||
| 117 | + | if fields[0] != "up" { | |||
| 118 | + | if err := sendPointer(ports.PointerButton{Button: code, Pressed: true, TimeMsec: now}); err != nil { | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | if fields[0] != "down" { | |||
| 123 | + | if err := sendPointer(ports.PointerButton{Button: code, TimeMsec: now}); err != nil { | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | continue | |||
| 128 | + | } | |||
| 70 | switch { | 129 | switch { | ||
| 71 | case strings.HasPrefix(line, "type "): | 130 | case strings.HasPrefix(line, "type "): | ||
| 72 | for _, r := range strings.TrimPrefix(line, "type ") { | 131 | for _, r := range strings.TrimPrefix(line, "type ") { | ||
M internal/adapters/headlessinput/input_test.go +29 -0
| @@ -54,3 +54,32 @@ func TestScript(t *testing.T) { | |||
| 54 | 54 | }) | |
| 55 | 55 | } | |
| 56 | 56 | } | |
| 57 | + | ||
| 58 | + | func TestPointerScript(t *testing.T) { | |
| 59 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |
| 60 | + | if err != nil { | |
| 61 | + | t.Skip(err) | |
| 62 | + | } | |
| 63 | + | script := make(chan string, 4) | |
| 64 | + | for _, line := range []string{"move 12.5 45", "click", "down right", "up right"} { | |
| 65 | + | script <- line | |
| 66 | + | } | |
| 67 | + | close(script) | |
| 68 | + | input := make(chan ports.InputEvent, 8) | |
| 69 | + | if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 72 | + | if len(input) != 5 { | |
| 73 | + | t.Fatalf("events: %d", len(input)) | |
| 74 | + | } | |
| 75 | + | m := (<-input).(ports.PointerMotion) | |
| 76 | + | if m.X != 12.5 || m.Y != 45 { | |
| 77 | + | t.Fatal(m) | |
| 78 | + | } | |
| 79 | + | for _, want := range []ports.PointerButton{{Button: 0x110, Pressed: true}, {Button: 0x110}, {Button: 0x111, Pressed: true}, {Button: 0x111}} { | |
| 80 | + | got := (<-input).(ports.PointerButton) | |
| 81 | + | if got.Button != want.Button || got.Pressed != want.Pressed { | |
| 82 | + | t.Fatalf("got %+v want %+v", got, want) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| @@ -54,3 +54,32 @@ func TestScript(t *testing.T) { | |||||
| 54 | }) | 54 | }) | ||
| 55 | } | 55 | } | ||
| 56 | } | 56 | } | ||
| 57 | + | ||||
| 58 | + | func TestPointerScript(t *testing.T) { | |||
| 59 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |||
| 60 | + | if err != nil { | |||
| 61 | + | t.Skip(err) | |||
| 62 | + | } | |||
| 63 | + | script := make(chan string, 4) | |||
| 64 | + | for _, line := range []string{"move 12.5 45", "click", "down right", "up right"} { | |||
| 65 | + | script <- line | |||
| 66 | + | } | |||
| 67 | + | close(script) | |||
| 68 | + | input := make(chan ports.InputEvent, 8) | |||
| 69 | + | if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil { | |||
| 70 | + | t.Fatal(err) | |||
| 71 | + | } | |||
| 72 | + | if len(input) != 5 { | |||
| 73 | + | t.Fatalf("events: %d", len(input)) | |||
| 74 | + | } | |||
| 75 | + | m := (<-input).(ports.PointerMotion) | |||
| 76 | + | if m.X != 12.5 || m.Y != 45 { | |||
| 77 | + | t.Fatal(m) | |||
| 78 | + | } | |||
| 79 | + | for _, want := range []ports.PointerButton{{Button: 0x110, Pressed: true}, {Button: 0x110}, {Button: 0x111, Pressed: true}, {Button: 0x111}} { | |||
| 80 | + | got := (<-input).(ports.PointerButton) | |||
| 81 | + | if got.Button != want.Button || got.Pressed != want.Pressed { | |||
| 82 | + | t.Fatalf("got %+v want %+v", got, want) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | } | |||
M internal/adapters/wayland/globals.go +30 -4
| @@ -52,9 +52,9 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 52 | 52 | return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) { | |
| 53 | 53 | r, e := wayland.NewSeat(c, int32(v), id, seat{s}) | |
| 54 | 54 | if e == nil { | |
| 55 | - | capabilities := uint32(0) | |
| 55 | + | capabilities := uint32(wayland.SeatCapabilityPointer) | |
| 56 | 56 | if s.keymapFD >= 0 { | |
| 57 | - | capabilities = uint32(wayland.SeatCapabilityKeyboard) | |
| 57 | + | capabilities |= uint32(wayland.SeatCapabilityKeyboard) | |
| 58 | 58 | } | |
| 59 | 59 | r.SendCapabilities(capabilities) | |
| 60 | 60 | if v >= 2 { | |
| @@ -243,8 +243,32 @@ func (output) Release(*wayland.Output) {} | |||
| 243 | 243 | ||
| 244 | 244 | type seat struct{ server *Server } | |
| 245 | 245 | ||
| 246 | - | func (seat) GetPointer(r *wayland.Seat, id uint32) { | |
| 247 | - | wayland.NewPointer(r.Client(), r.Version(), id, pointer{}) | |
| 246 | + | func (h seat) GetPointer(r *wayland.Seat, id uint32) { | |
| 247 | + | p, err := wayland.NewPointer(r.Client(), r.Version(), id, pointer{}) | |
| 248 | + | if err != nil { | |
| 249 | + | return | |
| 250 | + | } | |
| 251 | + | s := h.server | |
| 252 | + | s.pointers[r.Client()] = append(s.pointers[r.Client()], p) | |
| 253 | + | p.OnDestroy = func() { | |
| 254 | + | list := s.pointers[r.Client()] | |
| 255 | + | for i, item := range list { | |
| 256 | + | if item == p { | |
| 257 | + | list = append(list[:i], list[i+1:]...) | |
| 258 | + | break | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | if len(list) == 0 { | |
| 262 | + | delete(s.pointers, r.Client()) | |
| 263 | + | } else { | |
| 264 | + | s.pointers[r.Client()] = list | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | if w := s.windows[s.pointerFocus]; w != nil && w.mapped && w.xdg.resource.Client() == r.Client() { | |
| 268 | + | s.serial++ | |
| 269 | + | p.SendEnter(s.serial, w.xdg.surfaceResource(), server.FixedFromFloat(s.pointerX), server.FixedFromFloat(s.pointerY)) | |
| 270 | + | pointerFrame(p) | |
| 271 | + | } | |
| 248 | 272 | } | |
| 249 | 273 | func (h seat) GetKeyboard(r *wayland.Seat, id uint32) { | |
| 250 | 274 | k, err := wayland.NewKeyboard(r.Client(), r.Version(), id, keyboard{}) | |
| @@ -287,6 +311,8 @@ func (seat) Release(*wayland.Seat) {} | |||
| 287 | 311 | ||
| 288 | 312 | type pointer struct{} | |
| 289 | 313 | ||
| 314 | + | // Cursor surfaces are accepted but rendering cursors is not implemented yet. | |
| 315 | + | ||
| 290 | 316 | func (pointer) SetCursor(*wayland.Pointer, uint32, *wayland.Surface, int32, int32) {} | |
| 291 | 317 | func (pointer) Release(*wayland.Pointer) {} | |
| 292 | 318 | ||
| @@ -52,9 +52,9 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 52 | return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) { | 52 | return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) { | ||
| 53 | r, e := wayland.NewSeat(c, int32(v), id, seat{s}) | 53 | r, e := wayland.NewSeat(c, int32(v), id, seat{s}) | ||
| 54 | if e == nil { | 54 | if e == nil { | ||
| 55 | - | capabilities := uint32(0) | 55 | + | capabilities := uint32(wayland.SeatCapabilityPointer) |
| 56 | if s.keymapFD >= 0 { | 56 | if s.keymapFD >= 0 { | ||
| 57 | - | capabilities = uint32(wayland.SeatCapabilityKeyboard) | 57 | + | capabilities |= uint32(wayland.SeatCapabilityKeyboard) |
| 58 | } | 58 | } | ||
| 59 | r.SendCapabilities(capabilities) | 59 | r.SendCapabilities(capabilities) | ||
| 60 | if v >= 2 { | 60 | if v >= 2 { | ||
| @@ -243,8 +243,32 @@ func (output) Release(*wayland.Output) {} | |||||
| 243 | 243 | ||||
| 244 | type seat struct{ server *Server } | 244 | type seat struct{ server *Server } | ||
| 245 | 245 | ||||
| 246 | - | func (seat) GetPointer(r *wayland.Seat, id uint32) { | 246 | + | func (h seat) GetPointer(r *wayland.Seat, id uint32) { |
| 247 | - | wayland.NewPointer(r.Client(), r.Version(), id, pointer{}) | 247 | + | p, err := wayland.NewPointer(r.Client(), r.Version(), id, pointer{}) |
| 248 | + | if err != nil { | |||
| 249 | + | return | |||
| 250 | + | } | |||
| 251 | + | s := h.server | |||
| 252 | + | s.pointers[r.Client()] = append(s.pointers[r.Client()], p) | |||
| 253 | + | p.OnDestroy = func() { | |||
| 254 | + | list := s.pointers[r.Client()] | |||
| 255 | + | for i, item := range list { | |||
| 256 | + | if item == p { | |||
| 257 | + | list = append(list[:i], list[i+1:]...) | |||
| 258 | + | break | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | if len(list) == 0 { | |||
| 262 | + | delete(s.pointers, r.Client()) | |||
| 263 | + | } else { | |||
| 264 | + | s.pointers[r.Client()] = list | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | if w := s.windows[s.pointerFocus]; w != nil && w.mapped && w.xdg.resource.Client() == r.Client() { | |||
| 268 | + | s.serial++ | |||
| 269 | + | p.SendEnter(s.serial, w.xdg.surfaceResource(), server.FixedFromFloat(s.pointerX), server.FixedFromFloat(s.pointerY)) | |||
| 270 | + | pointerFrame(p) | |||
| 271 | + | } | |||
| 248 | } | 272 | } | ||
| 249 | func (h seat) GetKeyboard(r *wayland.Seat, id uint32) { | 273 | func (h seat) GetKeyboard(r *wayland.Seat, id uint32) { | ||
| 250 | k, err := wayland.NewKeyboard(r.Client(), r.Version(), id, keyboard{}) | 274 | k, err := wayland.NewKeyboard(r.Client(), r.Version(), id, keyboard{}) | ||
| @@ -287,6 +311,8 @@ func (seat) Release(*wayland.Seat) {} | |||||
| 287 | 311 | ||||
| 288 | type pointer struct{} | 312 | type pointer struct{} | ||
| 289 | 313 | ||||
| 314 | + | // Cursor surfaces are accepted but rendering cursors is not implemented yet. | |||
| 315 | + | ||||
| 290 | func (pointer) SetCursor(*wayland.Pointer, uint32, *wayland.Surface, int32, int32) {} | 316 | func (pointer) SetCursor(*wayland.Pointer, uint32, *wayland.Surface, int32, int32) {} | ||
| 291 | func (pointer) Release(*wayland.Pointer) {} | 317 | func (pointer) Release(*wayland.Pointer) {} | ||
| 292 | 318 | ||||
M internal/adapters/wayland/keyboard_test.go +1 -1
| @@ -65,7 +65,7 @@ func TestKeyboardInfo(t *testing.T) { | |||
| 65 | 65 | if err != nil { | |
| 66 | 66 | t.Fatalf("wayland-info: %v: %s", err, out) | |
| 67 | 67 | } | |
| 68 | - | for _, want := range []string{"capabilities: keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} { | |
| 68 | + | for _, want := range []string{"capabilities: pointer keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} { | |
| 69 | 69 | if !strings.Contains(string(out), want) { | |
| 70 | 70 | t.Errorf("missing %q: %s", want, out) | |
| 71 | 71 | } | |
| @@ -65,7 +65,7 @@ func TestKeyboardInfo(t *testing.T) { | |||||
| 65 | if err != nil { | 65 | if err != nil { | ||
| 66 | t.Fatalf("wayland-info: %v: %s", err, out) | 66 | t.Fatalf("wayland-info: %v: %s", err, out) | ||
| 67 | } | 67 | } | ||
| 68 | - | for _, want := range []string{"capabilities: keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} { | 68 | + | for _, want := range []string{"capabilities: pointer keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} { |
| 69 | if !strings.Contains(string(out), want) { | 69 | if !strings.Contains(string(out), want) { | ||
| 70 | t.Errorf("missing %q: %s", want, out) | 70 | t.Errorf("missing %q: %s", want, out) | ||
| 71 | } | 71 | } | ||
M internal/adapters/wayland/server.go +69 -1
| @@ -45,6 +45,9 @@ type Server struct { | |||
| 45 | 45 | windows map[ports.WindowID]*window | |
| 46 | 46 | nextWindow ports.WindowID | |
| 47 | 47 | focused ports.WindowID | |
| 48 | + | pointerFocus ports.WindowID | |
| 49 | + | pointerX, pointerY float64 | |
| 50 | + | pointers map[server.Client][]*wayland.Pointer | |
| 48 | 51 | modState ports.ModState | |
| 49 | 52 | keyboards map[server.Client][]*wayland.Keyboard | |
| 50 | 53 | keymapFD int | |
| @@ -87,7 +90,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 87 | 90 | d.Close() | |
| 88 | 91 | return nil, err | |
| 89 | 92 | } | |
| 90 | - | s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 93 | + | s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 91 | 94 | if opts.Keymap != "" { | |
| 92 | 95 | if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) { | |
| 93 | 96 | cleanup() | |
| @@ -292,6 +295,28 @@ func (s *Server) forwardContents(ctx context.Context) { | |||
| 292 | 295 | ||
| 293 | 296 | func (s *Server) apply(cmd ports.ClientCommand) { | |
| 294 | 297 | switch c := cmd.(type) { | |
| 298 | + | case ports.PointerFocus: | |
| 299 | + | s.changePointerFocus(c.ID, c.X, c.Y) | |
| 300 | + | case ports.PointerMotionTo: | |
| 301 | + | if c.ID == s.pointerFocus { | |
| 302 | + | for _, p := range s.windowPointers(s.windows[c.ID]) { | |
| 303 | + | p.SendMotion(c.TimeMsec, server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y)) | |
| 304 | + | pointerFrame(p) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | case ports.PointerButtonTo: | |
| 308 | + | // Releases may target the implicit-grab window after focus has moved. | |
| 309 | + | if w := s.windows[c.ID]; w != nil { | |
| 310 | + | for _, p := range s.windowPointers(w) { | |
| 311 | + | s.serial++ | |
| 312 | + | state := uint32(0) | |
| 313 | + | if c.Pressed { | |
| 314 | + | state = 1 | |
| 315 | + | } | |
| 316 | + | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | |
| 317 | + | pointerFrame(p) | |
| 318 | + | } | |
| 319 | + | } | |
| 295 | 320 | case ports.FocusWindow: | |
| 296 | 321 | if s.focused != c.ID { | |
| 297 | 322 | s.changeFocus(c.ID) | |
| @@ -379,3 +404,46 @@ func (s *Server) changeFocus(id ports.WindowID) { | |||
| 379 | 404 | } | |
| 380 | 405 | } | |
| 381 | 406 | } | |
| 407 | + | ||
| 408 | + | func pointerFrame(p *wayland.Pointer) { | |
| 409 | + | if p.Version() >= 5 { | |
| 410 | + | p.SendFrame() | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | func (s *Server) windowPointers(w *window) []*wayland.Pointer { | |
| 414 | + | if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() { | |
| 415 | + | return nil | |
| 416 | + | } | |
| 417 | + | var result []*wayland.Pointer | |
| 418 | + | for _, p := range s.pointers[w.xdg.resource.Client()] { | |
| 419 | + | if p.Resource.Alive() { | |
| 420 | + | result = append(result, p) | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | return result | |
| 424 | + | } | |
| 425 | + | func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) { | |
| 426 | + | if id == s.pointerFocus { | |
| 427 | + | return | |
| 428 | + | } | |
| 429 | + | if old := s.windows[s.pointerFocus]; old != nil { | |
| 430 | + | if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() { | |
| 431 | + | for _, p := range s.windowPointers(old) { | |
| 432 | + | s.serial++ | |
| 433 | + | p.SendLeave(s.serial, surface) | |
| 434 | + | pointerFrame(p) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | } | |
| 438 | + | s.pointerFocus = 0 | |
| 439 | + | if w := s.windows[id]; w != nil { | |
| 440 | + | if surface := w.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() { | |
| 441 | + | s.pointerFocus, s.pointerX, s.pointerY = id, x, y | |
| 442 | + | for _, p := range s.windowPointers(w) { | |
| 443 | + | s.serial++ | |
| 444 | + | p.SendEnter(s.serial, surface, server.FixedFromFloat(x), server.FixedFromFloat(y)) | |
| 445 | + | pointerFrame(p) | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | } | |
| @@ -45,6 +45,9 @@ type Server struct { | |||||
| 45 | windows map[ports.WindowID]*window | 45 | windows map[ports.WindowID]*window | ||
| 46 | nextWindow ports.WindowID | 46 | nextWindow ports.WindowID | ||
| 47 | focused ports.WindowID | 47 | focused ports.WindowID | ||
| 48 | + | pointerFocus ports.WindowID | |||
| 49 | + | pointerX, pointerY float64 | |||
| 50 | + | pointers map[server.Client][]*wayland.Pointer | |||
| 48 | modState ports.ModState | 51 | modState ports.ModState | ||
| 49 | keyboards map[server.Client][]*wayland.Keyboard | 52 | keyboards map[server.Client][]*wayland.Keyboard | ||
| 50 | keymapFD int | 53 | keymapFD int | ||
| @@ -87,7 +90,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 87 | d.Close() | 90 | d.Close() | ||
| 88 | return nil, err | 91 | return nil, err | ||
| 89 | } | 92 | } | ||
| 90 | - | s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | 93 | + | s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} |
| 91 | if opts.Keymap != "" { | 94 | if opts.Keymap != "" { | ||
| 92 | if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) { | 95 | if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) { | ||
| 93 | cleanup() | 96 | cleanup() | ||
| @@ -292,6 +295,28 @@ func (s *Server) forwardContents(ctx context.Context) { | |||||
| 292 | 295 | ||||
| 293 | func (s *Server) apply(cmd ports.ClientCommand) { | 296 | func (s *Server) apply(cmd ports.ClientCommand) { | ||
| 294 | switch c := cmd.(type) { | 297 | switch c := cmd.(type) { | ||
| 298 | + | case ports.PointerFocus: | |||
| 299 | + | s.changePointerFocus(c.ID, c.X, c.Y) | |||
| 300 | + | case ports.PointerMotionTo: | |||
| 301 | + | if c.ID == s.pointerFocus { | |||
| 302 | + | for _, p := range s.windowPointers(s.windows[c.ID]) { | |||
| 303 | + | p.SendMotion(c.TimeMsec, server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y)) | |||
| 304 | + | pointerFrame(p) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | case ports.PointerButtonTo: | |||
| 308 | + | // Releases may target the implicit-grab window after focus has moved. | |||
| 309 | + | if w := s.windows[c.ID]; w != nil { | |||
| 310 | + | for _, p := range s.windowPointers(w) { | |||
| 311 | + | s.serial++ | |||
| 312 | + | state := uint32(0) | |||
| 313 | + | if c.Pressed { | |||
| 314 | + | state = 1 | |||
| 315 | + | } | |||
| 316 | + | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | |||
| 317 | + | pointerFrame(p) | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 295 | case ports.FocusWindow: | 320 | case ports.FocusWindow: | ||
| 296 | if s.focused != c.ID { | 321 | if s.focused != c.ID { | ||
| 297 | s.changeFocus(c.ID) | 322 | s.changeFocus(c.ID) | ||
| @@ -379,3 +404,46 @@ func (s *Server) changeFocus(id ports.WindowID) { | |||||
| 379 | } | 404 | } | ||
| 380 | } | 405 | } | ||
| 381 | } | 406 | } | ||
| 407 | + | ||||
| 408 | + | func pointerFrame(p *wayland.Pointer) { | |||
| 409 | + | if p.Version() >= 5 { | |||
| 410 | + | p.SendFrame() | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | func (s *Server) windowPointers(w *window) []*wayland.Pointer { | |||
| 414 | + | if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() { | |||
| 415 | + | return nil | |||
| 416 | + | } | |||
| 417 | + | var result []*wayland.Pointer | |||
| 418 | + | for _, p := range s.pointers[w.xdg.resource.Client()] { | |||
| 419 | + | if p.Resource.Alive() { | |||
| 420 | + | result = append(result, p) | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | return result | |||
| 424 | + | } | |||
| 425 | + | func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) { | |||
| 426 | + | if id == s.pointerFocus { | |||
| 427 | + | return | |||
| 428 | + | } | |||
| 429 | + | if old := s.windows[s.pointerFocus]; old != nil { | |||
| 430 | + | if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() { | |||
| 431 | + | for _, p := range s.windowPointers(old) { | |||
| 432 | + | s.serial++ | |||
| 433 | + | p.SendLeave(s.serial, surface) | |||
| 434 | + | pointerFrame(p) | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | } | |||
| 438 | + | s.pointerFocus = 0 | |||
| 439 | + | if w := s.windows[id]; w != nil { | |||
| 440 | + | if surface := w.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() { | |||
| 441 | + | s.pointerFocus, s.pointerX, s.pointerY = id, x, y | |||
| 442 | + | for _, p := range s.windowPointers(w) { | |||
| 443 | + | s.serial++ | |||
| 444 | + | p.SendEnter(s.serial, surface, server.FixedFromFloat(x), server.FixedFromFloat(y)) | |||
| 445 | + | pointerFrame(p) | |||
| 446 | + | } | |||
| 447 | + | } | |||
| 448 | + | } | |||
| 449 | + | } | |||
M internal/adapters/wayland/xdg.go +3 -0
| @@ -80,6 +80,9 @@ type window struct { | |||
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | 82 | func (w *window) unmap() { | |
| 83 | + | if w.xdg.server.pointerFocus == w.id { | |
| 84 | + | w.xdg.server.changePointerFocus(0, 0, 0) | |
| 85 | + | } | |
| 83 | 86 | if w.xdg.server.focused == w.id { | |
| 84 | 87 | w.xdg.server.changeFocus(0) | |
| 85 | 88 | } | |
| @@ -80,6 +80,9 @@ type window struct { | |||||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | func (w *window) unmap() { | 82 | func (w *window) unmap() { | ||
| 83 | + | if w.xdg.server.pointerFocus == w.id { | |||
| 84 | + | w.xdg.server.changePointerFocus(0, 0, 0) | |||
| 85 | + | } | |||
| 83 | if w.xdg.server.focused == w.id { | 86 | if w.xdg.server.focused == w.id { | ||
| 84 | w.xdg.server.changeFocus(0) | 87 | w.xdg.server.changeFocus(0) | ||
| 85 | } | 88 | } | ||
M internal/app/app_test.go +34 -0
| @@ -140,3 +140,37 @@ func TestHeadlessTyping(t *testing.T) { | |||
| 140 | 140 | } | |
| 141 | 141 | } | |
| 142 | 142 | } | |
| 143 | + | ||
| 144 | + | func TestHeadlessPointerClickFocus(t *testing.T) { | |
| 145 | + | if _, err := exec.LookPath("foot"); err != nil { | |
| 146 | + | t.Skip("foot unavailable") | |
| 147 | + | } | |
| 148 | + | t.Setenv("HOME", t.TempDir()) | |
| 149 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 150 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 151 | + | cfg := config.Defaults() | |
| 152 | + | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | |
| 153 | + | scenes := make(chan ports.Scene, 128) | |
| 154 | + | err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n"), Timeout: 5 * time.Second, testScenes: scenes}) | |
| 155 | + | if err != nil { | |
| 156 | + | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |
| 157 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 158 | + | } | |
| 159 | + | t.Fatal(err) | |
| 160 | + | } | |
| 161 | + | two, focused := false, false | |
| 162 | + | for len(scenes) > 0 { | |
| 163 | + | s := <-scenes | |
| 164 | + | if len(s.Windows) == 2 { | |
| 165 | + | two = true | |
| 166 | + | for _, w := range s.Windows { | |
| 167 | + | if w.ID == 1 && w.Focused { | |
| 168 | + | focused = true | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | if !two || !focused { | |
| 174 | + | t.Fatalf("two columns=%v first focused=%v", two, focused) | |
| 175 | + | } | |
| 176 | + | } | |
| @@ -140,3 +140,37 @@ func TestHeadlessTyping(t *testing.T) { | |||||
| 140 | } | 140 | } | ||
| 141 | } | 141 | } | ||
| 142 | } | 142 | } | ||
| 143 | + | ||||
| 144 | + | func TestHeadlessPointerClickFocus(t *testing.T) { | |||
| 145 | + | if _, err := exec.LookPath("foot"); err != nil { | |||
| 146 | + | t.Skip("foot unavailable") | |||
| 147 | + | } | |||
| 148 | + | t.Setenv("HOME", t.TempDir()) | |||
| 149 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 150 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 151 | + | cfg := config.Defaults() | |||
| 152 | + | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | |||
| 153 | + | scenes := make(chan ports.Scene, 128) | |||
| 154 | + | err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n"), Timeout: 5 * time.Second, testScenes: scenes}) | |||
| 155 | + | if err != nil { | |||
| 156 | + | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |||
| 157 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 158 | + | } | |||
| 159 | + | t.Fatal(err) | |||
| 160 | + | } | |||
| 161 | + | two, focused := false, false | |||
| 162 | + | for len(scenes) > 0 { | |||
| 163 | + | s := <-scenes | |||
| 164 | + | if len(s.Windows) == 2 { | |||
| 165 | + | two = true | |||
| 166 | + | for _, w := range s.Windows { | |||
| 167 | + | if w.ID == 1 && w.Focused { | |||
| 168 | + | focused = true | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | if !two || !focused { | |||
| 174 | + | t.Fatalf("two columns=%v first focused=%v", two, focused) | |||
| 175 | + | } | |||
| 176 | + | } | |||
M internal/core/core.go +78 -9
| @@ -28,14 +28,18 @@ type binding struct { | |||
| 28 | 28 | key string | |
| 29 | 29 | } | |
| 30 | 30 | type Core struct { | |
| 31 | - | ch Channels | |
| 32 | - | cfg ports.Config | |
| 33 | - | ws Workspace | |
| 34 | - | binds map[binding]Action | |
| 35 | - | pressed map[string]bool | |
| 36 | - | sent map[WindowID]ports.ConfigureWindow | |
| 37 | - | focus WindowID | |
| 38 | - | seq uint64 | |
| 31 | + | ch Channels | |
| 32 | + | cfg ports.Config | |
| 33 | + | ws Workspace | |
| 34 | + | binds map[binding]Action | |
| 35 | + | pressed map[string]bool | |
| 36 | + | sent map[WindowID]ports.ConfigureWindow | |
| 37 | + | focus WindowID | |
| 38 | + | pointer WindowID | |
| 39 | + | grab WindowID | |
| 40 | + | buttons map[uint32]bool | |
| 41 | + | cursorX, cursorY float64 | |
| 42 | + | seq uint64 | |
| 39 | 43 | } | |
| 40 | 44 | ||
| 41 | 45 | func keyName(s string) string { | |
| @@ -124,7 +128,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 124 | 128 | if cap(ch.Scenes) != 1 { | |
| 125 | 129 | return nil, fmt.Errorf("scenes must have capacity 1") | |
| 126 | 130 | } | |
| 127 | - | c := &Core{ch: ch, pressed: map[string]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 131 | + | c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 128 | 132 | if err := c.apply(cfg); err != nil { | |
| 129 | 133 | return nil, err | |
| 130 | 134 | } | |
| @@ -205,6 +209,12 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 205 | 209 | c.ws.AddWindow(v.ID) | |
| 206 | 210 | case ports.WindowUnmapped: | |
| 207 | 211 | c.ws.RemoveWindow(v.ID) | |
| 212 | + | if c.pointer == v.ID { | |
| 213 | + | c.pointer = 0 | |
| 214 | + | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |
| 215 | + | return nil | |
| 216 | + | } | |
| 217 | + | } | |
| 208 | 218 | case ports.WindowFullscreenRequest: | |
| 209 | 219 | c.ws.SetFullscreen(v.ID, v.Fullscreen) | |
| 210 | 220 | } | |
| @@ -245,6 +255,65 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 245 | 255 | c.ch.Input = nil | |
| 246 | 256 | continue | |
| 247 | 257 | } | |
| 258 | + | switch v := ev.(type) { | |
| 259 | + | case ports.PointerMotion: | |
| 260 | + | c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output.W-1, 0))) | |
| 261 | + | c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output.H-1, 0))) | |
| 262 | + | var id WindowID | |
| 263 | + | var x, y float64 | |
| 264 | + | // Fullscreen wins; otherwise the last visible placement is topmost. | |
| 265 | + | for _, p := range c.ws.Layout() { | |
| 266 | + | r := p.Rect | |
| 267 | + | if !p.Hidden && r.W > 0 && r.H > 0 && c.cursorX >= float64(r.X) && c.cursorX < float64(r.X+r.W) && c.cursorY >= float64(r.Y) && c.cursorY < float64(r.Y+r.H) { | |
| 268 | + | if id == 0 || p.Fullscreen { | |
| 269 | + | id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y) | |
| 270 | + | } | |
| 271 | + | if p.Fullscreen { | |
| 272 | + | break | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | // Layout changes are intentionally re-hit-tested only on motion. | |
| 277 | + | if id != c.pointer { | |
| 278 | + | c.pointer = id | |
| 279 | + | if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil { | |
| 280 | + | return nil | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | if id != 0 { | |
| 284 | + | if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: v.TimeMsec}); err != nil { | |
| 285 | + | return nil | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | continue | |
| 289 | + | case ports.PointerButton: | |
| 290 | + | id := c.pointer | |
| 291 | + | if c.grab != 0 { | |
| 292 | + | id = c.grab | |
| 293 | + | } | |
| 294 | + | if v.Pressed { | |
| 295 | + | if len(c.buttons) == 0 { | |
| 296 | + | c.grab = id | |
| 297 | + | } | |
| 298 | + | c.buttons[v.Button] = true | |
| 299 | + | } else { | |
| 300 | + | delete(c.buttons, v.Button) | |
| 301 | + | } | |
| 302 | + | if id != 0 { | |
| 303 | + | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |
| 304 | + | return nil | |
| 305 | + | } | |
| 306 | + | if v.Pressed && c.focus != id && c.ws.FocusID(id) { | |
| 307 | + | if err := c.publish(ctx); err != nil { | |
| 308 | + | return nil | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | if len(c.buttons) == 0 { | |
| 313 | + | c.grab = 0 | |
| 314 | + | } | |
| 315 | + | continue | |
| 316 | + | } | |
| 248 | 317 | key, ok := ev.(ports.KeyEvent) | |
| 249 | 318 | if !ok { | |
| 250 | 319 | continue | |
| @@ -28,14 +28,18 @@ type binding struct { | |||||
| 28 | key string | 28 | key string | ||
| 29 | } | 29 | } | ||
| 30 | type Core struct { | 30 | type Core struct { | ||
| 31 | - | ch Channels | 31 | + | ch Channels |
| 32 | - | cfg ports.Config | 32 | + | cfg ports.Config |
| 33 | - | ws Workspace | 33 | + | ws Workspace |
| 34 | - | binds map[binding]Action | 34 | + | binds map[binding]Action |
| 35 | - | pressed map[string]bool | 35 | + | pressed map[string]bool |
| 36 | - | sent map[WindowID]ports.ConfigureWindow | 36 | + | sent map[WindowID]ports.ConfigureWindow |
| 37 | - | focus WindowID | 37 | + | focus WindowID |
| 38 | - | seq uint64 | 38 | + | pointer WindowID |
| 39 | + | grab WindowID | |||
| 40 | + | buttons map[uint32]bool | |||
| 41 | + | cursorX, cursorY float64 | |||
| 42 | + | seq uint64 | |||
| 39 | } | 43 | } | ||
| 40 | 44 | ||||
| 41 | func keyName(s string) string { | 45 | func keyName(s string) string { | ||
| @@ -124,7 +128,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||||
| 124 | if cap(ch.Scenes) != 1 { | 128 | if cap(ch.Scenes) != 1 { | ||
| 125 | return nil, fmt.Errorf("scenes must have capacity 1") | 129 | return nil, fmt.Errorf("scenes must have capacity 1") | ||
| 126 | } | 130 | } | ||
| 127 | - | c := &Core{ch: ch, pressed: map[string]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | 131 | + | c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} |
| 128 | if err := c.apply(cfg); err != nil { | 132 | if err := c.apply(cfg); err != nil { | ||
| 129 | return nil, err | 133 | return nil, err | ||
| 130 | } | 134 | } | ||
| @@ -205,6 +209,12 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 205 | c.ws.AddWindow(v.ID) | 209 | c.ws.AddWindow(v.ID) | ||
| 206 | case ports.WindowUnmapped: | 210 | case ports.WindowUnmapped: | ||
| 207 | c.ws.RemoveWindow(v.ID) | 211 | c.ws.RemoveWindow(v.ID) | ||
| 212 | + | if c.pointer == v.ID { | |||
| 213 | + | c.pointer = 0 | |||
| 214 | + | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |||
| 215 | + | return nil | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 208 | case ports.WindowFullscreenRequest: | 218 | case ports.WindowFullscreenRequest: | ||
| 209 | c.ws.SetFullscreen(v.ID, v.Fullscreen) | 219 | c.ws.SetFullscreen(v.ID, v.Fullscreen) | ||
| 210 | } | 220 | } | ||
| @@ -245,6 +255,65 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 245 | c.ch.Input = nil | 255 | c.ch.Input = nil | ||
| 246 | continue | 256 | continue | ||
| 247 | } | 257 | } | ||
| 258 | + | switch v := ev.(type) { | |||
| 259 | + | case ports.PointerMotion: | |||
| 260 | + | c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output.W-1, 0))) | |||
| 261 | + | c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output.H-1, 0))) | |||
| 262 | + | var id WindowID | |||
| 263 | + | var x, y float64 | |||
| 264 | + | // Fullscreen wins; otherwise the last visible placement is topmost. | |||
| 265 | + | for _, p := range c.ws.Layout() { | |||
| 266 | + | r := p.Rect | |||
| 267 | + | if !p.Hidden && r.W > 0 && r.H > 0 && c.cursorX >= float64(r.X) && c.cursorX < float64(r.X+r.W) && c.cursorY >= float64(r.Y) && c.cursorY < float64(r.Y+r.H) { | |||
| 268 | + | if id == 0 || p.Fullscreen { | |||
| 269 | + | id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y) | |||
| 270 | + | } | |||
| 271 | + | if p.Fullscreen { | |||
| 272 | + | break | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | // Layout changes are intentionally re-hit-tested only on motion. | |||
| 277 | + | if id != c.pointer { | |||
| 278 | + | c.pointer = id | |||
| 279 | + | if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil { | |||
| 280 | + | return nil | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | if id != 0 { | |||
| 284 | + | if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: v.TimeMsec}); err != nil { | |||
| 285 | + | return nil | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | continue | |||
| 289 | + | case ports.PointerButton: | |||
| 290 | + | id := c.pointer | |||
| 291 | + | if c.grab != 0 { | |||
| 292 | + | id = c.grab | |||
| 293 | + | } | |||
| 294 | + | if v.Pressed { | |||
| 295 | + | if len(c.buttons) == 0 { | |||
| 296 | + | c.grab = id | |||
| 297 | + | } | |||
| 298 | + | c.buttons[v.Button] = true | |||
| 299 | + | } else { | |||
| 300 | + | delete(c.buttons, v.Button) | |||
| 301 | + | } | |||
| 302 | + | if id != 0 { | |||
| 303 | + | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |||
| 304 | + | return nil | |||
| 305 | + | } | |||
| 306 | + | if v.Pressed && c.focus != id && c.ws.FocusID(id) { | |||
| 307 | + | if err := c.publish(ctx); err != nil { | |||
| 308 | + | return nil | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | if len(c.buttons) == 0 { | |||
| 313 | + | c.grab = 0 | |||
| 314 | + | } | |||
| 315 | + | continue | |||
| 316 | + | } | |||
| 248 | key, ok := ev.(ports.KeyEvent) | 317 | key, ok := ev.(ports.KeyEvent) | ||
| 249 | if !ok { | 318 | if !ok { | ||
| 250 | continue | 319 | continue | ||
M internal/core/core_test.go +54 -0
| @@ -192,3 +192,57 @@ func TestBoundReleaseSwallowed(t *testing.T) { | |||
| 192 | 192 | t.Fatal(v) | |
| 193 | 193 | } | |
| 194 | 194 | } | |
| 195 | + | ||
| 196 | + | func TestPointerFocusAndGrab(t *testing.T) { | |
| 197 | + | cfg := config.Defaults() | |
| 198 | + | client := make(chan ports.ClientEvent, 8) | |
| 199 | + | input := make(chan ports.InputEvent, 8) | |
| 200 | + | output := make(chan ports.OutputEvent, 8) | |
| 201 | + | commands := make(chan ports.ClientCommand, 64) | |
| 202 | + | scenes := make(chan ports.Scene, 1) | |
| 203 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 204 | + | if err != nil { | |
| 205 | + | t.Fatal(err) | |
| 206 | + | } | |
| 207 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 208 | + | defer cancel() | |
| 209 | + | go c.Run(ctx) | |
| 210 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 211 | + | receive(t, scenes) | |
| 212 | + | client <- ports.WindowMapped{ID: 1} | |
| 213 | + | first := receive(t, scenes).Windows[0].Rect | |
| 214 | + | client <- ports.WindowMapped{ID: 2} | |
| 215 | + | receive(t, scenes) | |
| 216 | + | for len(commands) > 0 { | |
| 217 | + | <-commands | |
| 218 | + | } | |
| 219 | + | input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), TimeMsec: 1} | |
| 220 | + | if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) { | |
| 221 | + | t.Fatal(v) | |
| 222 | + | } | |
| 223 | + | if v := receive(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) { | |
| 224 | + | t.Fatal(v) | |
| 225 | + | } | |
| 226 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 227 | + | if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) { | |
| 228 | + | t.Fatal(v) | |
| 229 | + | } | |
| 230 | + | receive(t, scenes) | |
| 231 | + | found := false | |
| 232 | + | for len(commands) > 0 { | |
| 233 | + | if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 { | |
| 234 | + | found = true | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | if !found { | |
| 238 | + | t.Fatal("missing keyboard focus") | |
| 239 | + | } | |
| 240 | + | input <- ports.PointerMotion{X: 0, Y: 0} | |
| 241 | + | if v := receive(t, commands); v != (ports.PointerFocus{}) { | |
| 242 | + | t.Fatal(v) | |
| 243 | + | } | |
| 244 | + | input <- ports.PointerButton{Button: 0x110} | |
| 245 | + | if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) { | |
| 246 | + | t.Fatal(v) | |
| 247 | + | } | |
| 248 | + | } | |
| @@ -192,3 +192,57 @@ func TestBoundReleaseSwallowed(t *testing.T) { | |||||
| 192 | t.Fatal(v) | 192 | t.Fatal(v) | ||
| 193 | } | 193 | } | ||
| 194 | } | 194 | } | ||
| 195 | + | ||||
| 196 | + | func TestPointerFocusAndGrab(t *testing.T) { | |||
| 197 | + | cfg := config.Defaults() | |||
| 198 | + | client := make(chan ports.ClientEvent, 8) | |||
| 199 | + | input := make(chan ports.InputEvent, 8) | |||
| 200 | + | output := make(chan ports.OutputEvent, 8) | |||
| 201 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 202 | + | scenes := make(chan ports.Scene, 1) | |||
| 203 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 204 | + | if err != nil { | |||
| 205 | + | t.Fatal(err) | |||
| 206 | + | } | |||
| 207 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 208 | + | defer cancel() | |||
| 209 | + | go c.Run(ctx) | |||
| 210 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |||
| 211 | + | receive(t, scenes) | |||
| 212 | + | client <- ports.WindowMapped{ID: 1} | |||
| 213 | + | first := receive(t, scenes).Windows[0].Rect | |||
| 214 | + | client <- ports.WindowMapped{ID: 2} | |||
| 215 | + | receive(t, scenes) | |||
| 216 | + | for len(commands) > 0 { | |||
| 217 | + | <-commands | |||
| 218 | + | } | |||
| 219 | + | input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), TimeMsec: 1} | |||
| 220 | + | if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) { | |||
| 221 | + | t.Fatal(v) | |||
| 222 | + | } | |||
| 223 | + | if v := receive(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) { | |||
| 224 | + | t.Fatal(v) | |||
| 225 | + | } | |||
| 226 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 227 | + | if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) { | |||
| 228 | + | t.Fatal(v) | |||
| 229 | + | } | |||
| 230 | + | receive(t, scenes) | |||
| 231 | + | found := false | |||
| 232 | + | for len(commands) > 0 { | |||
| 233 | + | if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 { | |||
| 234 | + | found = true | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | if !found { | |||
| 238 | + | t.Fatal("missing keyboard focus") | |||
| 239 | + | } | |||
| 240 | + | input <- ports.PointerMotion{X: 0, Y: 0} | |||
| 241 | + | if v := receive(t, commands); v != (ports.PointerFocus{}) { | |||
| 242 | + | t.Fatal(v) | |||
| 243 | + | } | |||
| 244 | + | input <- ports.PointerButton{Button: 0x110} | |||
| 245 | + | if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) { | |||
| 246 | + | t.Fatal(v) | |||
| 247 | + | } | |||
| 248 | + | } | |||
M internal/core/layout.go +15 -0
| @@ -156,6 +156,21 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 156 | 156 | } | |
| 157 | 157 | } | |
| 158 | 158 | } | |
| 159 | + | ||
| 160 | + | // FocusID selects a window and scrolls its column into view. | |
| 161 | + | func (w *Workspace) FocusID(id WindowID) bool { | |
| 162 | + | for i := range w.Columns { | |
| 163 | + | for j, v := range w.Columns[i].Windows { | |
| 164 | + | if v == id { | |
| 165 | + | w.Focus = i | |
| 166 | + | w.Columns[i].Focus = j | |
| 167 | + | w.scroll() | |
| 168 | + | return true | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | return false | |
| 173 | + | } | |
| 159 | 174 | func (w *Workspace) FocusColumn(dir int) { | |
| 160 | 175 | if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | |
| 161 | 176 | w.Focus += dir | |
| @@ -156,6 +156,21 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||||
| 156 | } | 156 | } | ||
| 157 | } | 157 | } | ||
| 158 | } | 158 | } | ||
| 159 | + | ||||
| 160 | + | // FocusID selects a window and scrolls its column into view. | |||
| 161 | + | func (w *Workspace) FocusID(id WindowID) bool { | |||
| 162 | + | for i := range w.Columns { | |||
| 163 | + | for j, v := range w.Columns[i].Windows { | |||
| 164 | + | if v == id { | |||
| 165 | + | w.Focus = i | |||
| 166 | + | w.Columns[i].Focus = j | |||
| 167 | + | w.scroll() | |||
| 168 | + | return true | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | return false | |||
| 173 | + | } | |||
| 159 | func (w *Workspace) FocusColumn(dir int) { | 174 | func (w *Workspace) FocusColumn(dir int) { | ||
| 160 | if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | 175 | if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | ||
| 161 | w.Focus += dir | 176 | w.Focus += dir | ||
M internal/ports/messages.go +42 -0
| @@ -54,6 +54,23 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 } | |||
| 54 | 54 | ||
| 55 | 55 | func (KeyEvent) inputEvent() {} | |
| 56 | 56 | ||
| 57 | + | // PointerMotion uses absolute output coordinates. | |
| 58 | + | type PointerMotion struct { | |
| 59 | + | X, Y float64 | |
| 60 | + | TimeMsec uint32 | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (PointerMotion) inputEvent() {} | |
| 64 | + | ||
| 65 | + | // PointerButton uses evdev button codes (BTN_LEFT is 0x110). | |
| 66 | + | type PointerButton struct { | |
| 67 | + | Button uint32 | |
| 68 | + | Pressed bool | |
| 69 | + | TimeMsec uint32 | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (PointerButton) inputEvent() {} | |
| 73 | + | ||
| 57 | 74 | // OutputEvent carries output → core notifications. | |
| 58 | 75 | type OutputEvent interface{ outputEvent() } | |
| 59 | 76 | ||
| @@ -92,6 +109,31 @@ type FocusWindow struct{ ID WindowID } | |||
| 92 | 109 | ||
| 93 | 110 | func (FocusWindow) clientCommand() {} | |
| 94 | 111 | ||
| 112 | + | // PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local. | |
| 113 | + | type PointerFocus struct { | |
| 114 | + | ID WindowID | |
| 115 | + | X, Y float64 | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (PointerFocus) clientCommand() {} | |
| 119 | + | ||
| 120 | + | type PointerMotionTo struct { | |
| 121 | + | ID WindowID | |
| 122 | + | X, Y float64 | |
| 123 | + | TimeMsec uint32 | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (PointerMotionTo) clientCommand() {} | |
| 127 | + | ||
| 128 | + | type PointerButtonTo struct { | |
| 129 | + | ID WindowID | |
| 130 | + | Button uint32 | |
| 131 | + | Pressed bool | |
| 132 | + | TimeMsec uint32 | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (PointerButtonTo) clientCommand() {} | |
| 136 | + | ||
| 95 | 137 | // ForwardKey carries core → wayland unbound keys. | |
| 96 | 138 | type ForwardKey struct { | |
| 97 | 139 | ID WindowID | |
| @@ -54,6 +54,23 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 } | |||||
| 54 | 54 | ||||
| 55 | func (KeyEvent) inputEvent() {} | 55 | func (KeyEvent) inputEvent() {} | ||
| 56 | 56 | ||||
| 57 | + | // PointerMotion uses absolute output coordinates. | |||
| 58 | + | type PointerMotion struct { | |||
| 59 | + | X, Y float64 | |||
| 60 | + | TimeMsec uint32 | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (PointerMotion) inputEvent() {} | |||
| 64 | + | ||||
| 65 | + | // PointerButton uses evdev button codes (BTN_LEFT is 0x110). | |||
| 66 | + | type PointerButton struct { | |||
| 67 | + | Button uint32 | |||
| 68 | + | Pressed bool | |||
| 69 | + | TimeMsec uint32 | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (PointerButton) inputEvent() {} | |||
| 73 | + | ||||
| 57 | // OutputEvent carries output → core notifications. | 74 | // OutputEvent carries output → core notifications. | ||
| 58 | type OutputEvent interface{ outputEvent() } | 75 | type OutputEvent interface{ outputEvent() } | ||
| 59 | 76 | ||||
| @@ -92,6 +109,31 @@ type FocusWindow struct{ ID WindowID } | |||||
| 92 | 109 | ||||
| 93 | func (FocusWindow) clientCommand() {} | 110 | func (FocusWindow) clientCommand() {} | ||
| 94 | 111 | ||||
| 112 | + | // PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local. | |||
| 113 | + | type PointerFocus struct { | |||
| 114 | + | ID WindowID | |||
| 115 | + | X, Y float64 | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (PointerFocus) clientCommand() {} | |||
| 119 | + | ||||
| 120 | + | type PointerMotionTo struct { | |||
| 121 | + | ID WindowID | |||
| 122 | + | X, Y float64 | |||
| 123 | + | TimeMsec uint32 | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (PointerMotionTo) clientCommand() {} | |||
| 127 | + | ||||
| 128 | + | type PointerButtonTo struct { | |||
| 129 | + | ID WindowID | |||
| 130 | + | Button uint32 | |||
| 131 | + | Pressed bool | |||
| 132 | + | TimeMsec uint32 | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (PointerButtonTo) clientCommand() {} | |||
| 136 | + | ||||
| 95 | // ForwardKey carries core → wayland unbound keys. | 137 | // ForwardKey carries core → wayland unbound keys. | ||
| 96 | type ForwardKey struct { | 138 | type ForwardKey struct { | ||
| 97 | ID WindowID | 139 | ID WindowID | ||