Skip to content

Commit 89b6f1c

Browse files
authored
feat(hotkeys): glide the cursor smoothly while a relative-move key is held (#1626)
1 parent 4e6659f commit 89b6f1c

35 files changed

Lines changed: 1544 additions & 460 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Hard rules that apply everywhere:
6868
- **Errors**: use `derrors` (`derrors.New` / `derrors.Wrap`). Unsupported platform behavior returns `derrors.CodeNotSupported` explicitly — never a silent no-op; callers degrade via `IsNotSupported`.
6969
- **`modes.Handler` has a strict locking contract** — read `internal/app/modes/AGENTS.md` before touching modes or anything that calls back into the handler.
7070

71-
Runtime shape: a daemon plus a thin CLI. `neru launch` starts the daemon; other commands dial a per-user Unix socket (`$XDG_RUNTIME_DIR/neru/neru.sock`, else `$TMPDIR/neru-<uid>/neru.sock`, 0600 in a 0700 directory) or a per-user Windows named pipe (`\\.\pipe\neru-<SID>`) — transport in `internal/adapter/ipc`, handlers in `internal/app/ipcctrl`. The endpoint stays scoped to one user and never widens; `docs/ARCHITECTURE.md` (Runtime Shape) owns the detail. New user-facing behavior usually needs a CLI command, an IPC handler, and the service/mode work behind it (the `add-cli-command` skill walks it). Startup is a numbered, individually-unwound phase sequence in `internal/app/new.go`. Input flow: native event tap → `adapter/eventtap``app/modes/handler.go` → active `Mode``app/services/*` → adapter → native API.
71+
Runtime shape: a daemon plus a thin CLI. `neru launch` starts the daemon; other commands dial a per-user Unix socket (`$XDG_RUNTIME_DIR/neru/neru.sock`, else `$TMPDIR/neru-<uid>/neru.sock`, 0600 in a 0700 directory) or a per-user Windows named pipe (`\\.\pipe\neru-<SID>`) — transport in `internal/adapter/ipc`, handlers in `internal/app/ipcctrl`. The endpoint stays scoped to one user and never widens; `docs/ARCHITECTURE.md` (Runtime Shape) owns the detail. New user-facing behavior usually needs a CLI command, an IPC handler, and the service/mode work behind it (the `add-cli-command` skill walks it). Startup is a numbered, individually-unwound phase sequence in `internal/app/new.go`. Input flow: native event tap → `adapter/eventtap` → `app/modes/handler.go` → active `Mode` → `app/services/*` → adapter → native API. Keys steering the held-key glide leave that path at the handler (and at the hotkey binder): `app/heldmotion` integrates the held set on its own tick and posts to the system port directly.
7272

7373
Configuration is hot-reloadable TOML; adding an option touches five links every time and up to four more when it needs them, with a guardrail test behind most of them — read `internal/config/AGENTS.md` or use the `add-config-option` skill. One of the five is the option's **platform column**: every option, mode flag and action declares which of macOS, Linux and Windows writing it does anything on, beside the vocabulary that owns it, and writing an inert one warns at load rather than refusing (`docs/adr/0013-parity-is-measured-in-words-not-subsystems.md`).
7474

configs/default-config.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -416,12 +416,12 @@ duration_per_pixel = 1.00 # Ms per pixel for adaptive duration
416416
# Held-key repeat (fires scroll/page/mouse-move actions repeatedly while held)
417417
# See https://github.com/y3owk1n/neru/blob/main/docs/CONFIGURATION.md#held_repeat
418418
[held_repeat]
419-
enabled = false # Master toggle for held-key repeat
419+
enabled = false # Master toggle for held-key repeat (a held move_mouse_relative glides)
420420
initial_delay_ms = 50 # Delay before first repeat fires (ms)
421421
interval_ms = 50 # Interval between subsequent repeats (ms)
422-
accel_enabled = false # Ramp step distance up the longer the key is held
422+
accel_enabled = false # Ramp the glide's speed up the longer the key stays held
423423
accel_ramp_ms = 500 # Hold time to reach accel_max_multiplier (ms)
424-
accel_max_multiplier = 4.0 # Step distance multiplier at full ramp
424+
accel_max_multiplier = 4.0 # Speed multiplier at full ramp
425425
accel_targets = ["move_mouse_relative"] # Action names eligible for acceleration
426426

427427
# Systray

docs/ARCHITECTURE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,12 @@ clearer is covered in
262262
2. **Adapters**[adapter.go](../internal/adapter/eventtap/adapter.go)
263263
receives and dispatches them
264264
3. **Application**[handler.go](../internal/app/modes/handler.go) routes the
265-
key to the active [Mode](../internal/app/modes/base.go)
265+
key to the active [Mode](../internal/app/modes/base.go). A held direction
266+
key in the held-key glide is the one exception: the handler and the global
267+
hotkey binder both press it into
268+
[heldmotion](../internal/app/heldmotion/controller.go), whose fixed-rate
269+
loop integrates the held set into cursor moves and posts them straight to
270+
the system port, never through a mode
266271
4. **Service** — the mode calls into
267272
[hint_service.go](../internal/app/services/hint_service.go) and friends
268273
5. **Keyboard layout changes** — on macOS the mode-level CGEventTap rebuilds its

docs/CONFIGURATION.md

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,16 +1614,16 @@ duration_per_pixel = 1.0
16141614

16151615
## [held_repeat]
16161616

1617-
Repeatedly dispatches scroll, page, relative-mouse-move, and `move_cell` actions while the key is held, with a configurable initial delay and repeat interval. Disable held-key repeat entirely by setting `enabled = false`.
1617+
Repeatedly dispatches scroll, page, and `move_cell` actions while the key is held, with a configurable initial delay and repeat interval, and glides the cursor for a held `move_mouse_relative` (see [Glide](#glide)). Disable held-key behaviour entirely by setting `enabled = false`.
16181618

16191619
| Option | Type | Default | Description |
16201620
| ---------------------- | -------- | ------------------------- | ------------------------------------------------ |
1621-
| `enabled` | bool | `false` | Master toggle for held-key repeat |
1621+
| `enabled` | bool | `false` | Master toggle for held-key repeat and the glide |
16221622
| `initial_delay_ms` | int | `50` | Delay before first repeat fires (ms) |
16231623
| `interval_ms` | int | `50` | Interval between subsequent repeats (ms) |
1624-
| `accel_enabled` | bool | `false` | Ramp step distance up the longer the key is held |
1624+
| `accel_enabled` | bool | `false` | Ramp the glide's speed up the longer the key stays held |
16251625
| `accel_ramp_ms` | int | `500` | Hold time to reach `accel_max_multiplier` (ms) |
1626-
| `accel_max_multiplier` | float | `4.0` | Step distance multiplier at full ramp |
1626+
| `accel_max_multiplier` | float | `4.0` | Speed multiplier at full ramp |
16271627
| `accel_targets` | string[] | `["move_mouse_relative"]` | Action names eligible for acceleration |
16281628

16291629
```toml
@@ -1637,29 +1637,41 @@ accel_max_multiplier = 4.0
16371637
accel_targets = ["move_mouse_relative"]
16381638
```
16391639

1640+
### Glide
1641+
1642+
With `enabled = true`, holding a key bound to a lone `move_mouse_relative` does
1643+
not repeat its step. The key contributes a direction, read from the sign of its
1644+
`--dx`/`--dy`, to one continuous glide: the cursor starts moving the moment the
1645+
key goes down at the binding's step per `interval_ms` (the default 10px step
1646+
every 50ms is 200px/s) and keeps that speed until release. Two keys held
1647+
together move diagonally at the same speed as one key moves straight, and
1648+
opposite keys cancel; when held keys have different steps the larger one sets
1649+
the speed. A short tap still travels about one step.
1650+
1651+
The glide runs on its own 10ms tick from the moment a key goes down until the
1652+
last direction key is released, with a subpixel position so slow speeds stay
1653+
smooth, so `initial_delay_ms` does not apply to it. It works across monitors,
1654+
and a click or any other action fired while moving acts at the cursor's live
1655+
position. Scroll, page and `move_cell` keep their fixed step and repeat on
1656+
`interval_ms` as before.
1657+
16401658
### Acceleration
16411659

1642-
With `accel_enabled = true`, a held key ramps linearly from 1x to `accel_max_multiplier`
1643-
over `accel_ramp_ms`, then holds there until release. With the values above the
1644-
multiplier is 2.5x at 250ms and 4x from 500ms onward, so a binding of
1645-
`action move_mouse_relative --dx=20 --dy=0` moves 50px per tick at 250ms and 80px per
1646-
tick from 500ms. The repeat interval never changes, only the per-tick distance, which is
1647-
how pointer acceleration works at the OS level and avoids rescheduling the repeat timer
1648-
on every tick.
1649-
1650-
The ramp is measured from the first repeat, not from the key press, so `initial_delay_ms`
1651-
does not eat into it.
1652-
1653-
Acceleration only applies to actions listed in `accel_targets`. Scaling acts on the
1654-
action's `--dx`/`--dy` flags, which only `move_mouse_relative` accepts, so that is
1655-
currently the sole valid entry: anything else is a config error rather than a binding
1656-
that silently never accelerates. An empty `accel_targets` while `accel_enabled = true` is
1657-
rejected for the same reason. Scroll and page keep their fixed step.
1658-
1659-
`accel_enabled = true` while `enabled = false` is not an error: acceleration scales a
1660-
repeat, so with no repeat to scale it simply does nothing, and refusing the file would
1661-
stop you turning held-key repeat off without also unwinding the settings under it.
1662-
`neru config validate` reports it as a warning instead.
1660+
With `accel_enabled = true`, the glide's speed ramps linearly to
1661+
`accel_max_multiplier` times the binding's speed over `accel_ramp_ms`, then holds
1662+
there until release. With the values above a 10px binding is at 500px/s after
1663+
250ms and 800px/s from 500ms onward. Leave it off for a glide at constant speed.
1664+
1665+
Acceleration only applies to actions listed in `accel_targets`. Only
1666+
`move_mouse_relative` glides, so that is currently the sole valid entry:
1667+
anything else is a config error rather than a binding that silently never
1668+
accelerates. An empty `accel_targets` while `accel_enabled = true` is rejected
1669+
for the same reason.
1670+
1671+
`accel_enabled = true` while `enabled = false` is not an error: acceleration
1672+
shapes a glide, so with no glide to shape it simply does nothing, and refusing
1673+
the file would stop you turning held-key behaviour off without also unwinding
1674+
the settings under it. `neru config validate` reports it as a warning instead.
16631675

16641676
---
16651677

docs/CROSS_PLATFORM.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ answer.
167167
| **Scroll injection** | ✅ both axes | ✅ both axes ⁷ | ✅ both axes (uinput, virtual-pointer fallback) | ✅ both axes (uinput, libei fallback) | ✅ both axes ⁷ |
168168
| **Modified scroll (`--modifier`)** |`CGEventSetFlags` on every chunk | ✅ XTest key hold ⁷ | ✅ virtual keyboard + virtual pointer (uinput on Hyprland ⁹) | ✅ libei |`SendInput` key hold ⁷ |
169169
| **Smooth cursor animation** | ✅ (incl. relative, opt-in) | ✅ incl. relative, opt-in | ✅ incl. relative, opt-in | ✅ incl. relative, opt-in | ✅ incl. relative, opt-in |
170+
| **Held-key glide (`held_repeat`)** |`CGEventPost` per tick | ✅ XTest per tick | ✅ virtual-pointer relative motion per tick | ✅ libei per tick |`SetCursorPos` per tick |
170171
| **Smooth scroll animation** || ⚠️ whole notches only ³ | ✅ continuous axis ³ (whole notches when modified on Hyprland ⁹) | ⚠️ libei scroll delta, unverified ³ | ✅ 120ths of a notch ³ |
171172
| **Element discovery (hints)** | ✅ AXUIElement | ⚠️ AT-SPI walk | ⚠️ AT-SPI walk | ⚠️ AT-SPI walk | ⚠️ UIA, control view only |
172173
| **Overlay** | ✅ NSPanel + CoreAnimation | ✅ X11 + Cairo | ✅ layer-shell + Cairo | ✅ layer-shell + Cairo | ✅ DirectComposition + Direct2D (GDI fallback; windows/arm64 is GDI only ¹⁰) |
@@ -1042,8 +1043,8 @@ Current ports: `SystemPort`, `AccessibilityPort`, `OverlayPort`, `EventTapPort`,
10421043
`HotkeyPort`, `IPCPort`, `VisionPort`, `TextInputPort`, `KeyFeedPort`,
10431044
`AppWatcherPort`, `SystrayPort`, `FontResolver`.
10441045

1045-
Optional extensions (Tier 3): `RelativeCursorMover` and `CursorSynchronizer`
1046-
on `SystemPort`, `HotkeyReleaseRegistrar` and `HotkeyHealthReporter` on
1046+
Optional extensions (Tier 3): `RelativeCursorMover`, `CursorSynchronizer`
1047+
and `InstantCursorMover` on `SystemPort`, `HotkeyReleaseRegistrar` and `HotkeyHealthReporter` on
10471048
`HotkeyPort`, `OverlayKeyboardPassthroughReporter` on `EventTapPort`,
10481049
`OverlayCapabilityReporter` on `OverlayPort`, and `SyntheticModifierSink` on
10491050
the `tap.Tap` backend contract (Linux only, declared in a `_linux.go` file

internal/adapter/platform/darwin/mouse.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ func MoveMouse(point image.Point, bypassSmooth bool) {
105105
}
106106
}
107107

108+
// PostMouseMove posts one absolute cursor move (or drag, while a button is
109+
// held) and returns at once. Unlike MoveMouse it neither animates nor spins
110+
// the run loop, so a fixed-rate motion loop can call it every tick.
111+
func PostMouseMove(point image.Point) {
112+
cursorAnimator.stop()
113+
114+
eventType, button := dragEventType()
115+
postCursorMoveEvent(point, uint32(eventType), uint32(button))
116+
}
117+
108118
// MoveMouseSmooth moves the mouse cursor smoothly to the specified point.
109119
func MoveMouseSmooth(end image.Point, steps int, eventType, button uint32) {
110120
cursorAnimator.animateTo(end, steps, eventType, button)

internal/adapter/platform/darwin/system.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ func (s *SystemAdapter) MoveCursorBy(
126126
return MoveMouseRelativeSmooth(delta), nil
127127
}
128128

129+
// MoveCursorInstantly posts one cursor move without animating or waiting
130+
// (ports.InstantCursorMover).
131+
func (s *SystemAdapter) MoveCursorInstantly(ctx context.Context, point image.Point) error {
132+
PostMouseMove(point)
133+
134+
return nil
135+
}
136+
129137
// WaitForCursorIdle blocks until any in-flight cursor movement animation settles.
130138
func (s *SystemAdapter) WaitForCursorIdle(ctx context.Context) error {
131139
return cursorAnimator.wait(ctx)
@@ -226,6 +234,9 @@ var _ ports.SystemPort = (*SystemAdapter)(nil)
226234
// moves when smooth cursor is enabled).
227235
var _ ports.RelativeCursorMover = (*SystemAdapter)(nil)
228236

237+
// SystemAdapter posts single moves without waiting for the held-key glide.
238+
var _ ports.InstantCursorMover = (*SystemAdapter)(nil)
239+
229240
// Ensure SystemAdapter opts into settling in-flight cursor animations before
230241
// position-dependent actions.
231242
var _ ports.CursorSettler = (*SystemAdapter)(nil)

internal/adapter/platform/linux/system_common.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,33 @@ func (s *SystemAdapter) MoveCursorToPoint(
365365
return s.moveCursorDirect(point)
366366
}
367367

368+
// MoveCursorInstantly posts one cursor move and returns at once
369+
// (ports.InstantCursorMover). Both animators are stopped first so a stale
370+
// step cannot land after it. On wlroots the move is injected as a relative
371+
// delta against the client-side cache — the same reason MoveCursorBy stays in
372+
// delta space there: nothing can query the real pointer, so an absolute warp
373+
// would jump by however stale the cache is. X11 and KDE warp directly.
374+
func (s *SystemAdapter) MoveCursorInstantly(ctx context.Context, point image.Point) error {
375+
err := ctx.Err()
376+
if err != nil {
377+
return err
378+
}
379+
380+
s.relativeAnimator.stop()
381+
s.cursorAnimator.stop()
382+
383+
if s.backend == backendWaylandWlroots {
384+
cached, err := waylandCursorPosition()
385+
if err != nil {
386+
return err
387+
}
388+
389+
return wlrootsMoveCursorBy(point.Sub(cached))
390+
}
391+
392+
return s.moveCursorDirect(point)
393+
}
394+
368395
// MoveCursorBy applies a relative cursor move. With smooth cursor enabled
369396
// (smooth_cursor.move_mouse_enabled) the move animates over the fixed
370397
// per-move duration smooth_cursor.relative_movement_duration:
@@ -1081,3 +1108,6 @@ func darkModeCapability(value int, source darkModeSource, ok bool) ports.Feature
10811108
Detail: "current state: " + label + " (source=" + string(source) + ")",
10821109
}
10831110
}
1111+
1112+
// SystemAdapter posts single moves without waiting for the held-key glide.
1113+
var _ ports.InstantCursorMover = (*SystemAdapter)(nil)

internal/adapter/platform/linux/system_stub_contract_test.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,9 +406,10 @@ func TestSystemAdapter_StartsNoCompositorBridgeOffKDE(t *testing.T) {
406406
}
407407
}
408408

409-
// TestSystemAdapter_MoveCursorToPointReportsNotSupported covers the one mutating
410-
// method worth pinning here. It is safe on an unimplemented backend precisely
411-
// because nothing is wired up to move — which is the behavior being asserted.
409+
// TestSystemAdapter_MoveCursorToPointReportsNotSupported covers the two mutating
410+
// methods worth pinning here (its sibling below is the second). They are safe on
411+
// an unimplemented backend precisely because nothing is wired up to move — which
412+
// is the behavior being asserted.
412413
func TestSystemAdapter_MoveCursorToPointReportsNotSupported(t *testing.T) {
413414
adapter := linux.NewSystemAdapter(unimplementedBackend)
414415

@@ -422,3 +423,17 @@ func TestSystemAdapter_MoveCursorToPointReportsNotSupported(t *testing.T) {
422423
err, derrors.GetCode(err))
423424
}
424425
}
426+
427+
func TestSystemAdapter_MoveCursorInstantlyReportsNotSupported(t *testing.T) {
428+
adapter := linux.NewSystemAdapter(unimplementedBackend)
429+
430+
err := adapter.MoveCursorInstantly(context.Background(), image.Point{X: 10, Y: 10})
431+
if err == nil {
432+
t.Fatal("MoveCursorInstantly on an unimplemented backend returned nil")
433+
}
434+
435+
if !derrors.IsNotSupported(err) {
436+
t.Errorf("MoveCursorInstantly returned %v (code %q), want CodeNotSupported",
437+
err, derrors.GetCode(err))
438+
}
439+
}

internal/adapter/platform/windows/system.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,20 @@ func (s *SystemAdapter) MoveCursorToPoint(
234234
return moveCursorTo(point)
235235
}
236236

237+
// MoveCursorInstantly warps the cursor with SetCursorPos and returns at once
238+
// (ports.InstantCursorMover). Like the bypass branch of MoveCursorToPoint it
239+
// stops the animator first, so a stale glide step cannot land after the warp.
240+
func (s *SystemAdapter) MoveCursorInstantly(ctx context.Context, point image.Point) error {
241+
err := ctx.Err()
242+
if err != nil {
243+
return err
244+
}
245+
246+
s.cursorAnimator.stop()
247+
248+
return moveCursorTo(point)
249+
}
250+
237251
// MoveCursorBy applies a relative cursor move. With smooth cursor enabled
238252
// (smooth_cursor.move_mouse_enabled) the delta extends the animator's pending
239253
// endpoint, clamped to the active screen, and animates over the fixed
@@ -367,3 +381,6 @@ var (
367381
_ ports.RelativeCursorMover = (*SystemAdapter)(nil)
368382
_ ports.CursorSettler = (*SystemAdapter)(nil)
369383
)
384+
385+
// SystemAdapter posts single moves without waiting for the held-key glide.
386+
var _ ports.InstantCursorMover = (*SystemAdapter)(nil)

0 commit comments

Comments
 (0)