Skip to content

Commit 7b0fc43

Browse files
authored
fix(linux): follow the physical mouse when selecting the active monitor on Hyprland (#1447)
1 parent c4aedd0 commit 7b0fc43

10 files changed

Lines changed: 424 additions & 7 deletions

File tree

docs/CROSS_PLATFORM.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ or no-op) · ❌ no code path
130130
| **Focused app identity** | ✅ NSWorkspace + AX |`_NET_ACTIVE_WINDOW` / `WM_CLASS` | ⚠️ app_id only (see below) | ⚠️ app_id only |`GetForegroundWindow` |
131131
| **App watcher (focus change)**| ✅ NSWorkspace observer | ✅ event-driven | ✅ event-driven | ✅ event-driven | 🟡 |
132132
| **Keymap learns the focused app** | ✅ published by the watcher | ✅ published by the watcher | ✅ published by the watcher | ✅ published by the watcher | ⚠️ asked when the keymap settles ¹ |
133-
| **Cursor position** |`CGEventGetLocation` |`XQueryPointer` | ✅ sync-surface trick | ✅ sync-surface trick |`GetCursorPos` |
133+
| **Cursor position** |`CGEventGetLocation` |`XQueryPointer` |compositor IPC (Hyprland) / sync-surface trick | ✅ sync-surface trick |`GetCursorPos` |
134134
| **Cursor move** |`CGEventPost` ([`postMouseMoveLocked`](../internal/adapter/platform/darwin/accessibility_mouse_darwin.m)) | ✅ XTest (`XTestFakeMotionEvent`) |`zwlr_virtual_pointer` | ✅ libei |`SetCursorPos` |
135135
| **Mouse buttons / drag** |`CGEventPost` | ✅ XTest |`zwlr_virtual_pointer` | ✅ libei |`SendInput` |
136136
| **Scroll injection** | ✅ both axes | ✅ both axes | ✅ both axes (uinput + virtual pointer) | ✅ libei | ⚠️ vertical only |

internal/adapter/platform/linux/system_common.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ func (s *SystemAdapter) SyncCursorPosition(ctx context.Context) error {
463463
}
464464

465465
if s.waylandUsesWlrClientStack() {
466-
return waylandRefreshCursorPosition()
466+
return waylandRefreshCursorPosition(ctx)
467467
}
468468

469469
return nil
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//go:build linux
2+
3+
package linux
4+
5+
import (
6+
"context"
7+
"image"
8+
"os"
9+
)
10+
11+
// Wayland compositor-IPC cursor position. A Wayland client cannot query the
12+
// global pointer position, so the wlroots client keeps a cache that a plain
13+
// user-driven mouse move invalidates (wlroots_client.c). The layer-shell
14+
// discovery refresh corrects it, but depends on the compositor delivering a
15+
// wl_pointer.enter to a freshly mapped surface within its budget — which
16+
// Hyprland does not reliably do (#1279). Hyprland exposes the authoritative
17+
// position over its CLI instead, so the sync path asks it first and keeps
18+
// discovery as the fallback for the compositors that expose no such query
19+
// (Sway and niri today).
20+
21+
// waylandCompositorCursorPosition returns the physical cursor position from
22+
// the running compositor's IPC, when it exposes one. ok=false means "no such
23+
// query here" — the caller falls back to layer-shell discovery, so a stale
24+
// HYPRLAND_INSTANCE_SIGNATURE in a non-Hyprland session degrades to the
25+
// pre-IPC behavior (hyprctl fails to connect and reports nothing).
26+
//
27+
// Reached only behind waylandUsesWlrClientStack, which tests the backend the
28+
// adapter was built with; the socket variable picks the CLI, never the backend
29+
// (internal/adapter/platform/AGENTS.md).
30+
func waylandCompositorCursorPosition(ctx context.Context) (image.Point, bool) {
31+
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
32+
return image.Point{}, false
33+
}
34+
35+
return hyprlandCursorPosition(ctx)
36+
}
37+
38+
// hyprlandCursorPosition reads `hyprctl -j cursorpos`, whose x/y are global
39+
// layout (logical) pixels. That is the space the screen list already lives in:
40+
// the wlroots client fills each screen's origin and size from
41+
// zxdg_output_v1.logical_position/logical_size (neru_xdg_output_listener,
42+
// wlroots_client.c), so on a scaled monitor both sides shrink together and
43+
// containment checks against screen bounds stay consistent — the same
44+
// agreement hyprlandFocusedWindowBounds already relies on for `activewindow`.
45+
func hyprlandCursorPosition(ctx context.Context) (image.Point, bool) {
46+
var pos struct {
47+
X int `json:"x"`
48+
Y int `json:"y"`
49+
}
50+
if !compositorJSONContext(ctx, &pos, "hyprctl", "-j", "cursorpos") {
51+
return image.Point{}, false
52+
}
53+
54+
return image.Point{X: pos.X, Y: pos.Y}, true
55+
}

internal/adapter/platform/linux/system_focused_window.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import (
2222
// focusedWindowQueryTimeout bounds each compositor IPC call.
2323
const focusedWindowQueryTimeout = 500 * time.Millisecond
2424

25+
// compositorCLIPipeGuard bounds how long Output may keep waiting on the CLI's
26+
// stdout pipe after the context kills the process — a CLI that leaked a child
27+
// inheriting stdout would otherwise hold the pipe open indefinitely.
28+
const compositorCLIPipeGuard = time.Second
29+
2530
// coordPair is the length of an [x,y] / [w,h] JSON array.
2631
const coordPair = 2
2732

@@ -47,7 +52,19 @@ func compositorJSON(dst any, name string, args ...string) bool {
4752
ctx, cancel := context.WithTimeout(context.Background(), focusedWindowQueryTimeout)
4853
defer cancel()
4954

50-
out, err := exec.CommandContext(ctx, name, args...).Output()
55+
return compositorJSONContext(ctx, dst, name, args...)
56+
}
57+
58+
// compositorJSONContext is compositorJSON bounded by the caller's context, for
59+
// call sites that already carry a deadline (the pre-activation cursor sync).
60+
// The deadline is real: CommandContext kills the CLI when it expires, and the
61+
// pipe guard keeps Output from waiting past the kill — this can run under the
62+
// mode handler's lock, where an unbounded wait stops the keyboard.
63+
func compositorJSONContext(ctx context.Context, dst any, name string, args ...string) bool {
64+
cmd := exec.CommandContext(ctx, name, args...)
65+
cmd.WaitDelay = compositorCLIPipeGuard
66+
67+
out, err := cmd.Output()
5168
if err != nil {
5269
return false
5370
}

internal/adapter/platform/linux/system_wayland_input.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package linux
44

55
import (
6+
"context"
67
"image"
78
"os"
89
"time"
@@ -138,7 +139,23 @@ func waylandCursorPosition() (image.Point, error) {
138139
return wlrootsCursorPosition()
139140
}
140141

141-
func waylandRefreshCursorPosition() error {
142+
// waylandRefreshCursorPosition re-learns the physical cursor position after
143+
// user-driven mouse movement the daemon cannot observe. The compositor's own
144+
// IPC is authoritative and cheap where it exists (Hyprland), so it is asked
145+
// first and mirrored into the wlroots cache; the layer-shell discovery pass
146+
// stays as the fallback for compositors without such a query (#1279).
147+
func waylandRefreshCursorPosition(ctx context.Context) error {
148+
if point, ok := waylandCompositorCursorPosition(ctx); ok {
149+
return wlrootsSetCursor(point)
150+
}
151+
152+
// An IPC attempt that burned the whole deadline must not buy the discovery
153+
// pass a second budget: this can run under the mode handler's lock, and the
154+
// discovery wait is bounded internally, not by this context.
155+
if ctx.Err() != nil {
156+
return ctx.Err()
157+
}
158+
142159
return wlrootsRefreshCursorPosition()
143160
}
144161

0 commit comments

Comments
 (0)