Skip to content

Commit bed6ccf

Browse files
authored
feat(windows): horizontal scroll injection (#1578)
1 parent 320448d commit bed6ccf

11 files changed

Lines changed: 133 additions & 99 deletions

File tree

docs/CLI.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,7 @@ macOS, an AT-SPI walk on Linux whose coverage depends on the application, and a
106106
cached UI Automation walk of the control view on Windows. The `vision` strategy is the
107107
fallback where that tree is thin. See [Accessibility and hints](CROSS_PLATFORM.md#accessibility-and-hints).
108108

109-
² Two action subcommands are limited: `hide_cursor` and `show_cursor` are macOS
110-
only, and `scroll_left` / `scroll_right` have no effect on Windows. See
109+
² `hide_cursor` and `show_cursor` are macOS only. See
111110
[Action platform support](#action-platform-support).
112111

113112
---
@@ -577,7 +576,6 @@ Every action not listed here behaves identically on macOS, Linux, and Windows.
577576
| Action | macOS | Linux | Windows | Note |
578577
| --------------------------------- | :---: | :---: | :-----: | -------------------------------------------------------- |
579578
| `hide_cursor`, `show_cursor` | Yes | No | No | Uses a Quartz API with no cross-platform equivalent; a no-op elsewhere. |
580-
| `scroll_left`, `scroll_right` | Yes | Yes | No | Windows scroll injection ignores the horizontal delta. |
581579
| `move_monitor` | Yes | Yes | Yes | Requires more than one display. |
582580

583581
The injection mechanism differs per platform even where behaviour matches:
@@ -810,9 +808,8 @@ A modified scroll is what most applications read as zoom, so
810808
modifier is not implied by the binding: `"Ctrl+K" = "action scroll_up"` scrolls
811809
unmodified, and it is `--modifier ctrl` that makes it zoom.
812810

813-
**Platforms:** vertical scrolling works everywhere. Horizontal scrolling
814-
(`scroll_left`, `scroll_right`) is not implemented on Windows and has no effect
815-
there. For what each backend does with `--modifier`, see the
811+
**Platforms:** scrolling works on both axes everywhere. For what each backend
812+
does with `--modifier`, see the
816813
[capability matrix](CROSS_PLATFORM.md#capability-matrix).
817814

818815
**Examples**

docs/CROSS_PLATFORM.md

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ that is what [Known Gaps](#known-gaps) tracks, per
177177
| **Cursor position** |`CGEventGetLocation` |`XQueryPointer` | ✅ compositor IPC (Hyprland) / sync-surface trick | ✅ sync-surface trick |`GetCursorPos` |
178178
| **Cursor move** |`CGEventPost` ([`postMouseMoveLocked`](../internal/adapter/platform/darwin/accessibility_mouse_darwin.m)) | ✅ XTest (`XTestFakeMotionEvent`) |`zwlr_virtual_pointer` | ✅ libei |`SetCursorPos` |
179179
| **Mouse buttons / drag** |`CGEventPost` | ✅ XTest ⁷ |`zwlr_virtual_pointer` | ✅ libei |`SendInput` |
180-
| **Scroll injection** | ✅ both axes | ✅ both axes ⁷ | ✅ both axes (uinput + virtual pointer) | ✅ libei | ⚠️ vertical only |
180+
| **Scroll injection** | ✅ both axes | ✅ both axes ⁷ | ✅ both axes (uinput + virtual pointer) | ✅ libei | ✅ both axes |
181181
| **Modified scroll (`--modifier`)** |`CGEventSetFlags` on every chunk | ✅ XTest key hold ⁷ | ✅ virtual keyboard, uinput batch skipped (kept on Hyprland ⁹) | ✅ libei |`SendInput` key hold |
182182
| **Smooth cursor animation** | ✅ (incl. relative, opt-in) | ✅ incl. relative, opt-in | ✅ incl. relative, opt-in | ✅ incl. relative, opt-in ||
183183
| **Smooth scroll animation** || ⚠️ whole notches only ³ | ✅ continuous virtual-pointer axis ³ (whole notches when modified on Hyprland ⁹) | ⚠️ libei scroll delta, unverified ³ ||
@@ -620,9 +620,10 @@ Go; only the final injection primitive differs:
620620
| Linux Wayland KDE | libei via `org.freedesktop.portal.RemoteDesktop` |
621621
| Windows | `SendInput` / `SetCursorPos` |
622622

623-
**The one behavioral difference:** Windows `ScrollAtCursor` ignores `deltaX`, so
624-
horizontal scrolling is a no-op there. Everything else behaves the same on all
625-
three platforms.
623+
Scrolling behaves the same on all three platforms, both axes included: Windows
624+
posts `MOUSEEVENTF_HWHEEL` for the horizontal component, with the sign flipped
625+
because Win32 reads a positive horizontal notch as right where Neru, macOS and
626+
X11 read it as left.
626627

627628
**Modifiers on a scroll** reach the injection primitive by two different routes,
628629
because only one of the primitives has a field for them. macOS stamps
@@ -1105,8 +1106,6 @@ green in every cell while an option means nothing, which is exactly how
11051106
| `smooth_scroll.duration_per_pixel` | option |||| the Windows scroll is injected in one step; macOS and Linux animate it, and on X11 the steps are whole wheel notches because X has no smaller scroll to send |
11061107
| `hide_cursor` | action |||| a Wayland client may not hide another client's cursor, and the blessed Linux stack is Wayland; Windows has no equivalent either |
11071108
| `show_cursor` | action |||| a Wayland client may not hide another client's cursor, and the blessed Linux stack is Wayland; Windows has no equivalent either |
1108-
| `scroll_left` | action |||| the Windows wheel event carries no horizontal delta, so a sideways scroll injects nothing |
1109-
| `scroll_right` | action |||| the Windows wheel event carries no horizontal delta, so a sideways scroll injects nothing |
11101109
| `feed` | action |||| Windows has no key-injection path yet, so the key it would post is never sent; the key_feed capability reports stub to match |
11111110

11121111
<!-- END GENERATED PLATFORM SUPPORT -->
@@ -1208,12 +1207,11 @@ working, which is exactly why the build exists.
12081207
it stays declared everywhere and is tracked as this entry instead
12091208
4. Smooth cursor and smooth scroll animation — not implemented
12101209
5. Modifier passthrough and `PostModifierEvent` — no-ops
1211-
6. Horizontal scroll — `ScrollAtCursor` ignores `deltaX`
1212-
7. `monitor_select` mode — returns `CodeNotSupported`
1213-
8. Font resolution — alias mapping only, no system font enumeration
1214-
9. `neru services` — every subcommand returns `CodeNotSupported`, where macOS
1215-
installs a launchd agent and Linux a systemd user unit
1216-
10. IPC endpoint, client side — the daemon's endpoint is scoped to one user on
1210+
6. `monitor_select` mode — returns `CodeNotSupported`
1211+
7. Font resolution — alias mapping only, no system font enumeration
1212+
8. `neru services` — every subcommand returns `CodeNotSupported`, where macOS
1213+
installs a launchd agent and Linux a systemd user unit
1214+
9. IPC endpoint, client side — the daemon's endpoint is scoped to one user on
12171215
every platform, but only the Unix client checks that for itself before
12181216
connecting. A named pipe carries no ownership a client can read without
12191217
opening it, so the Windows CLI trusts the name it derives from its own SID.

docs/ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ The two largest open areas:
4444
CGO build rather than missing code, and whose remaining work is failing
4545
loudly with the remedy.
4646
- **Windows** — the remaining Known Gaps entries: notifications, UIA tree
47-
depth, animations, horizontal scroll and `monitor_select`.
47+
depth, animations and `monitor_select`.
4848

4949
GNOME Wayland remains unsupported; the daemon refuses to start there. Reviving
5050
it needs libei plus a GNOME Shell extension — see

internal/adapter/accessibility/native/windows/element.go

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"github.com/y3owk1n/neru/internal/adapter/platform/mousestate"
1212
winplatform "github.com/y3owk1n/neru/internal/adapter/platform/windows"
1313
"github.com/y3owk1n/neru/internal/config"
14-
"github.com/y3owk1n/neru/internal/derrors"
1514
"github.com/y3owk1n/neru/internal/domain/action"
1615
"github.com/y3owk1n/neru/internal/domain/element"
1716
)
@@ -322,22 +321,10 @@ func MouseUp(button action.MouseButton) error {
322321
return MouseUpAtPoint(pos, button, 0)
323322
}
324323

325-
// ScrollAtCursor scrolls the mouse, with modifiers presented as held.
326-
//
327-
// Windows scroll injection has no horizontal axis, so a horizontal delta is
328-
// dropped — a long-standing, documented no-op. Dropping a modifier along with
329-
// it is a different matter: the user asked for a zoom and would be told it
330-
// happened, so a horizontal scroll carrying one is refused instead.
324+
// ScrollAtCursor scrolls the mouse on both axes, with modifiers presented as
325+
// held.
331326
func ScrollAtCursor(deltaX int, deltaY int, modifiers action.Modifiers) error {
332-
if deltaY == 0 && deltaX != 0 && modifiers != 0 {
333-
return derrors.Newf(
334-
derrors.CodeNotSupported,
335-
"horizontal scroll is not supported on Windows, so its %s modifier cannot be honored",
336-
modifiers,
337-
)
338-
}
339-
340-
return winplatform.ScrollWheel(deltaY, modifiers)
327+
return winplatform.ScrollWheel(deltaX, deltaY, modifiers)
341328
}
342329

343330
// CurrentCursorPosition returns the cursor position.

internal/adapter/accessibility/native/windows/scroll_modifier_stub_contract_test.go

Lines changed: 0 additions & 41 deletions
This file was deleted.

internal/adapter/platform/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ The One Rule (root `AGENTS.md`) is enforced here hardest: non-darwin-tagged code
55
- **Start at `profile.go`** — source of truth for each subsystem's backend family and whether a backend needs CGO (a per-backend decision, not per-OS).
66
- **File slots** — use the existing slot, never invent layout: `*_darwin.go`, `*_windows.go`, `*_linux.go`, `*_other.go` (non-target fallback), `*_linux_common.go`, `*_linux_x11.go`, `*_linux_wayland.go`, `*_linux_wayland_<compositor>.go`. Inside a directory that is already one platform (`platform/linux/`, `overlay/linux/`, …) the OS token is dropped, because the directory carries it: `system_x11_cgo.go`, not `system_linux_x11_cgo.go`. `docs/CROSS_PLATFORM.md` ("File Layout Rules") owns the table and what each slot holds today; `internal/architecture/platform_slots_test.go` is what fails.
77
- **Factory**`factory.go` + build-tagged siblings are the only place that picks a `ports.SystemPort`. Linux adds a runtime axis: `backend_linux.go` detects the live compositor (wlroots / KDE / GNOME / other) and owns what is named after it — `IsWayland`, the `display_server` label; never probe the compositor elsewhere, ask `DetectLinuxBackend`. `internal/architecture/compositor_detector_test.go` pins that by confining the desktop-identity variables (`XDG_CURRENT_DESKTOP`, `XDG_SESSION_TYPE`, …) to that file. The wlroots socket trio (`SWAYSOCK`, `NIRI_SOCKET`, `HYPRLAND_INSTANCE_SIGNATURE`) answers a question the backend enum cannot — which compositor CLI to shell out to — so it stays readable, but only downstream of the backend and never as a way to decide it; `internal/architecture/compositor_socket_test.go` pins that and lists the files entitled to read it.
8-
- **Stubs are loud** — return `derrors.CodeNotSupported`, never a silent no-op, whenever the signature has an error to carry it. A `bool` answer has none, and `true` is not a silent no-op: `SystemPort.CheckScreenCapturePermission` and `platform.CheckAccessibilityPermissions` report "this platform has no such gate" where there is none, which `ports/system.go` specifies — and where there *is* one they report it, so that bool is a live consent state on macOS and on Linux's `wayland-kde`, whose portal ScreenCast grant is the only way to read a KWin screen — making them loud would re-prompt forever for a consent that is already granted. Contract tests pin the loudness per subsystem, not for every stub in the tree; each lives beside the stub it pins as `*stub_contract*_test.go`. The ones that exist are `platform/linux/system_stub_contract_test.go`, `overlay/linux/stub_contract_test.go`, `eventtap/linux/global_hotkey_stub_contract_nocgo_test.go`, `vision/adapter_stub_contract_other_test.go` and its Linux and Windows siblings `vision/adapter_stub_contract_linux_test.go` and `vision/adapter_stub_contract_windows_test.go` (the port is implemented there, so those files pin the *shape* of every answer rather than a refusal — capture and recognition each return a result or a reason, never neither and never both; on Windows the one `CodeNotSupported` recognition may answer with is a missing OCR language pack, and it has to say so), `scroll_modifier_stub_contract_test.go` and `scroll_stub_contract_nocgo_test.go` in `accessibility/native/linux` (the first of those also in `accessibility/native/windows`), and `overlay_stub_contract_other_test.go` in each of `overlay/render/{grid,hints,modeindicator}`. The nocgo one is worth reading before adding a background path anywhere: it pins that `smooth_scroll` cannot turn a loud `CodeNotSupported` into silence, because the backend is detected from the environment and says nothing about whether this build can inject. Write a new one when a caller could read the stub's `nil` as success. Keep `ports/capabilities.go` / `capability_presets.go` honest: a stub reports `stub`, not `supported` (`neru doctor` reports this matrix).
8+
- **Stubs are loud** — return `derrors.CodeNotSupported`, never a silent no-op, whenever the signature has an error to carry it. A `bool` answer has none, and `true` is not a silent no-op: `SystemPort.CheckScreenCapturePermission` and `platform.CheckAccessibilityPermissions` report "this platform has no such gate" where there is none, which `ports/system.go` specifies — and where there *is* one they report it, so that bool is a live consent state on macOS and on Linux's `wayland-kde`, whose portal ScreenCast grant is the only way to read a KWin screen — making them loud would re-prompt forever for a consent that is already granted. Contract tests pin the loudness per subsystem, not for every stub in the tree; each lives beside the stub it pins as `*stub_contract*_test.go`. The ones that exist are `platform/linux/system_stub_contract_test.go`, `overlay/linux/stub_contract_test.go`, `eventtap/linux/global_hotkey_stub_contract_nocgo_test.go`, `vision/adapter_stub_contract_other_test.go` and its Linux and Windows siblings `vision/adapter_stub_contract_linux_test.go` and `vision/adapter_stub_contract_windows_test.go` (the port is implemented there, so those files pin the *shape* of every answer rather than a refusal — capture and recognition each return a result or a reason, never neither and never both; on Windows the one `CodeNotSupported` recognition may answer with is a missing OCR language pack, and it has to say so), `scroll_modifier_stub_contract_test.go` and `scroll_stub_contract_nocgo_test.go` in `accessibility/native/linux`, and `overlay_stub_contract_other_test.go` in each of `overlay/render/{grid,hints,modeindicator}`. The nocgo one is worth reading before adding a background path anywhere: it pins that `smooth_scroll` cannot turn a loud `CodeNotSupported` into silence, because the backend is detected from the environment and says nothing about whether this build can inject. Write a new one when a caller could read the stub's `nil` as success. Keep `ports/capabilities.go` / `capability_presets.go` honest: a stub reports `stub`, not `supported` (`neru doctor` reports this matrix).
99
- **Coordinates** — shared code is global top-left origin, Y down, unscaled pixels. Cocoa's bottom-left flip happens inside the darwin adapter, open-coded at each site that needs it rather than in one file; the whole of what the rule buys is that no flipped value reaches shared Go. `docs/ARCHITECTURE.md` ("Coordinate System") owns the detail, including which packages are *not* where a flip lives.
1010
- **`linux/wlr_protocol/` is generated** (`just generate-all-protocols`); never hand-edit.
1111

internal/adapter/platform/windows/input.go

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const (
2626
mouseeventfMiddleDown = 0x0020
2727
mouseeventfMiddleUp = 0x0040
2828
mouseeventfWheel = 0x0800
29+
mouseeventfHWheel = 0x1000
2930
mouseeventfAbsolute = 0x8000
3031

3132
keyeventfKeyUp = 0x0002
@@ -218,15 +219,49 @@ func MouseUp(point image.Point, button action.MouseButton) error {
218219
return sendMouseInput(flagsForButton(button).up, 0)
219220
}
220221

221-
// ScrollWheel scrolls vertically at the current cursor position, holding
222+
// wheelEvent is one MOUSEEVENTF_WHEEL or MOUSEEVENTF_HWHEEL record, before
223+
// it is posted.
224+
type wheelEvent struct {
225+
flags uint32
226+
data uint32
227+
}
228+
229+
// wheelEvents turns a scroll delta into the wheel records SendInput needs,
230+
// one per axis that moves. Deltas follow Neru's shared convention: positive
231+
// deltaY scrolls up and positive deltaX scrolls left, which is what macOS
232+
// posts verbatim and what X11 maps to buttons 4 and 6. MOUSEEVENTF_WHEEL
233+
// agrees on the vertical sign, but MOUSEEVENTF_HWHEEL reads positive as
234+
// right, so the horizontal component is negated.
235+
func wheelEvents(deltaX, deltaY int) []wheelEvent {
236+
var events []wheelEvent
237+
238+
if deltaY != 0 {
239+
events = append(events, wheelEvent{
240+
flags: mouseeventfWheel,
241+
data: uint32(int32(deltaY) * wheelDelta),
242+
})
243+
}
244+
245+
if deltaX != 0 {
246+
events = append(events, wheelEvent{
247+
flags: mouseeventfHWheel,
248+
data: uint32(int32(-deltaX) * wheelDelta),
249+
})
250+
}
251+
252+
return events
253+
}
254+
255+
// ScrollWheel scrolls at the current cursor position on both axes, holding
222256
// modifiers down for the duration.
223257
//
224258
// A SendInput wheel event carries no modifier field — unlike a CGEvent, which
225259
// takes flags — so the only way to present a held ctrl is to press the real
226260
// key, wheel, and release it. Releasing only what this call pressed leaves a
227261
// modifier the user is physically holding untouched.
228-
func ScrollWheel(deltaLines int, modifiers action.Modifiers) error {
229-
if deltaLines == 0 {
262+
func ScrollWheel(deltaX, deltaY int, modifiers action.Modifiers) error {
263+
events := wheelEvents(deltaX, deltaY)
264+
if len(events) == 0 {
230265
return nil
231266
}
232267

@@ -238,7 +273,14 @@ func ScrollWheel(deltaLines int, modifiers action.Modifiers) error {
238273
// Only what this call actually pressed, never the whole requested set.
239274
defer releaseModifiers(pressed)
240275

241-
return sendMouseInput(mouseeventfWheel, uint32(int32(deltaLines)*wheelDelta))
276+
for _, event := range events {
277+
err := sendMouseInput(event.flags, event.data)
278+
if err != nil {
279+
return err
280+
}
281+
}
282+
283+
return nil
242284
}
243285

244286
// modifierKeys lists the virtual-key code (keys.go) for each modifier bit, in

0 commit comments

Comments
 (0)