Skip to content

Commit 97587e4

Browse files
authored
feat(windows): grid and recursive-grid transition animation (#1583)
1 parent 3fcb25e commit 97587e4

13 files changed

Lines changed: 807 additions & 136 deletions

File tree

docs/CROSS_PLATFORM.md

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -923,7 +923,7 @@ important thing to know before touching overlay code:
923923

924924
| Animation | macOS | Linux X11 / Wayland | Windows |
925925
| ---------------------------- | ------------------------------------ | ---------------------------------- | ---------------------------------- |
926-
| **Grid transition** | CoreAnimation, ease-in-out @120Hz | goroutine, smoothstep @120fps | |
926+
| **Grid transition** | NSTimer @120Hz, ease-in-out, full redraw | goroutine, ease-in-out @120fps | goroutine, ease-in-out @120fps, presented on the UI thread |
927927
| **Mouse action indicator** | `CABasicAnimation` (scale + opacity) | goroutine, scale + opacity @120fps | goroutine, cubic easing @60fps |
928928
| **Smooth cursor** | ✅ stepped linear interpolation | ✅ stepped linear interpolation ||
929929
| **Smooth scroll** | ✅ ease-out cubic |||
@@ -946,10 +946,9 @@ discovery rather than the mode itself.
946946
| **Hints** | Search input badge || ✅ Cairo badge ||
947947
| **Hints** | Label arrow / tail | ✅ NSBezierPath | ✅ Cairo triangle | ✅ sampled triangle, see below |
948948
| **Hints** | Label placement | ✅ top / center / bottom | ✅ top / center / bottom | ✅ top / center / bottom |
949-
| **Grid** | Transition animation ||||
950949
| **Grid** | Virtual pointer indicator ||||
951950
| **Grid** | What an open subgrid shows | ✅ the subgrid alone | ✅ the subgrid alone | ⚠️ the parent cells return under it on the next repaint |
952-
| **Recursive grid**| Transition animation ||| |
951+
| **Recursive grid**| Transition animation ||| |
953952
| **Recursive grid**| Virtual pointer indicator ||||
954953
| **Recursive grid**| Sub-key preview | ✅ mini-grid of next keys | ✅ mini-grid of next keys | ✅ mini-grid of next keys |
955954
| **Scroll** | Smooth scroll animation || ✅ (X11: whole notches) ||
@@ -1078,8 +1077,6 @@ green in every cell while an option means nothing, which is exactly how
10781077
| `hints.vision.rectangle_min_size` | option |||| rectangle detection has no OCR answer, so it stays macOS-only even where the vision strategy lands; that half is text-only |
10791078
| `hints.vision.rectangle_min_aspect` | option |||| rectangle detection has no OCR answer, so it stays macOS-only even where the vision strategy lands; that half is text-only |
10801079
| `hints.vision.rectangle_max_aspect` | option |||| rectangle detection has no OCR answer, so it stays macOS-only even where the vision strategy lands; that half is text-only |
1081-
| `recursive_grid.animation.enabled` | option |||| the Windows overlay backend has no grid transition animation |
1082-
| `recursive_grid.animation.duration_ms` | option |||| the Windows overlay backend has no grid transition animation |
10831080
| `smooth_cursor.move_mouse_enabled` | option |||| cursor movement is not animated on Windows |
10841081
| `smooth_cursor.steps` | option |||| cursor movement is not animated on Windows |
10851082
| `smooth_cursor.max_duration` | option |||| cursor movement is not animated on Windows |
@@ -1186,12 +1183,11 @@ working, which is exactly why the build exists.
11861183
**Windows**
11871184

11881185
1. Native notifications — no toast support
1189-
2. Grid and recursive-grid transition animation — not implemented
1190-
3. Smooth cursor and smooth scroll animation — not implemented
1191-
4. Font resolution — alias mapping only, no system font enumeration
1192-
5. `neru services` — every subcommand returns `CodeNotSupported`, where macOS
1186+
2. Smooth cursor and smooth scroll animation — not implemented
1187+
3. Font resolution — alias mapping only, no system font enumeration
1188+
4. `neru services` — every subcommand returns `CodeNotSupported`, where macOS
11931189
installs a launchd agent and Linux a systemd user unit
1194-
6. IPC endpoint, client side — the daemon's endpoint is scoped to one user on
1190+
5. IPC endpoint, client side — the daemon's endpoint is scoped to one user on
11951191
every platform, but only the Unix client checks that for itself before
11961192
connecting. A named pipe carries no ownership a client can read without
11971193
opening it, so the Windows CLI trusts the name it derives from its own SID.

internal/adapter/overlay/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
These rules fail silently at runtime, not at compile time; read before editing.
44

55
- **Threading is platform-asymmetric.** macOS serializes through the Obj-C bridge (`dispatch_async` to the main thread); Linux must serialize itself — Cairo/X11/Wayland calls are not thread-safe (`linux/manager.go`).
6-
- **A draw may block, and must never be called while the mode handler's lock is held.** Draws dispatch asynchronously on macOS but hold `renderMu` synchronously on Linux; that asymmetry is deliberate, so callers must assume the blocking case. Windows sits between: a draw holds `renderMu` while it queues commands, and `Flush` hands the frame to the overlay UI thread and returns — painting and presenting happen there, and frames that pile up coalesce (`platform/windows/overlay.go`), so nothing on the keyboard hook's thread waits for pixels. The mode handler computes what to draw under its lock and draws after releasing it (`internal/app/modes/AGENTS.md`); the exceptions are the whole hints surface — the update callback (`hintdraw.go`), the theme refresh and the search input, which now go through the port but still draw under the handler lock, and whose first draw per activation runs the entire `ShowFrame` transition (resize, show, switch) under it — both grid surfaces, whose activations, redraws and per-keystroke updates all run under it (#1211) — and mode teardown, which hides indicators and clears the frame under it. `h.mu` → `renderMu` is therefore a real edge, and safe only while the reverse never exists: nothing holding `renderMu` may call into the app layer or publish to a subscriber that takes `h.mu`.
6+
- **A draw may block, and must never be called while the mode handler's lock is held.** Draws dispatch asynchronously on macOS but hold `renderMu` synchronously on Linux; that asymmetry is deliberate, so callers must assume the blocking case. Windows sits between: a draw holds `renderMu` while it queues commands, and `Flush` hands the frame to the overlay UI thread and returns — painting and presenting happen there, and frames that pile up coalesce (`platform/windows/overlay.go`), so nothing on the keyboard hook's thread waits for pixels; the recursive-grid transition there is a goroutine that takes `renderMu` per frame and hands each one to that thread the same way (`windows/transition.go`), and every draw that repaints the surface cancels it under the lock first. The mode handler computes what to draw under its lock and draws after releasing it (`internal/app/modes/AGENTS.md`); the exceptions are the whole hints surface — the update callback (`hintdraw.go`), the theme refresh and the search input, which now go through the port but still draw under the handler lock, and whose first draw per activation runs the entire `ShowFrame` transition (resize, show, switch) under it — both grid surfaces, whose activations, redraws and per-keystroke updates all run under it (#1211) — and mode teardown, which hides indicators and clears the frame under it. `h.mu` → `renderMu` is therefore a real edge, and safe only while the reverse never exists: nothing holding `renderMu` may call into the app layer or publish to a subscriber that takes `h.mu`.
77
- **Lock topology is deliberate.** The manager owns `renderMu`, held across synchronous draws; animation goroutines lock it via `sharedOverlay`. The mouse-action indicator owns an independent X11/Wayland connection and must **not** share `renderMu` — it has its own `indicatorMu` / `indicatorRenderMu`. **Canceling an animation is the one thing that happens outside it** (#1490): `cancelAnimation` waits for a goroutine that takes `renderMu` on every frame, so every Linux manager method that stops one cancels *before* it takes the lock — and then re-reads the backend pointer under it, because the gap is where a `Destroy` lands. The corollary is that no repaint inside a backend may reach `clear()`, whose first act is that cancel; the ones that clear a surface they are about to redraw go through the `surfaceClear` primitive instead.
88
- **Surface primitives split** (#1177): layout, animation, offsets, and label logic live once on `sharedOverlay`; only buffer management, HiDPI scale, and window lifecycle go behind `overlaySurface`. Shared code never touches cgo — primitives take Go types and own their C marshaling, including CString lifetimes.
99
- **The Linux backends' exported methods live on `sharedOverlay`, and the manager's nil check is what makes calling one safe** (#1415, ADR 0010): eighteen of them — every draw plus `Hide`, `Clear`, `ClearRect`, `Flush`, `SetHideUnmatched`, `HideHintSearchInput`, `setOriginOffset`, and the pair grid mode's pointer stand-in travels on, `SetGridPointer` / `forgetGridPointer` (#1463) — are declared once and promoted into `x11Overlay` / `wlrootsOverlay`. Each guards itself with `sharedOverlay.drawable()` — is a surface wired, and does `alive()`, the question `overlaySurface` now declares and each backend answers against its own `raw`, still say the native handle is open — in place of the `o.raw != nil` prologue it used to carry. Only `Show`, `Resize` and `Destroy` stay per-backend, because only those three genuinely differ. A promoted method reached through a **nil** backend pointer panics on the promotion, before any receiver guard inside could run — so every dispatch in `linux/manager.go` nil-checks the pointer first, and reads it once (`cancelBackendAnimation`) rather than twice. Those checks are not an interface nobody wrote; ADR 0010 is why, and deleting one turns a silent no-op into a crash. The `!cgo` twins deliberately keep their methods per-backend: their constructors always return nil, so every body exists precisely to be reached on a nil receiver. `Get()` must never return a typed nil either, or every `!= nil` guard downstream silently passes (`backend_linux.go`).

internal/adapter/overlay/linux/animation_cgo.go

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"image"
77
"math"
88
"time"
9+
10+
"github.com/y3owk1n/neru/internal/adapter/overlay/render/motion"
911
)
1012

1113
const (
@@ -34,30 +36,6 @@ const (
3436
alphaRoundBias = 0.5
3537
)
3638

37-
// easeInOut applies a smoothstep ease-in-out interpolation.
38-
// Matches the visual feel of kCAMediaTimingFunctionEaseInEaseOut on macOS.
39-
func easeInOut(progress float64) float64 {
40-
const (
41-
smoothStep3 = 3
42-
smoothStep2 = 2
43-
)
44-
45-
if progress <= 0 {
46-
return 0
47-
}
48-
49-
if progress >= 1 {
50-
return 1
51-
}
52-
53-
return progress * progress * (smoothStep3 - smoothStep2*progress)
54-
}
55-
56-
// lerp linearly interpolates between a and b by t.
57-
func lerp(a, b, t float64) float64 {
58-
return a + (b-a)*t
59-
}
60-
6139
// applyEasing maps a linear progress in [0,1] through the named easing curve,
6240
// matching the easing names accepted by the mouse-action-indicator config
6341
// (linear, ease_in, ease_out, ease_in_out). Unknown names fall back to
@@ -77,7 +55,7 @@ func applyEasing(easing string, progress float64) float64 {
7755
case easingEaseIn:
7856
return progress * progress * progress
7957
case easingEaseInOut:
80-
return easeInOut(progress)
58+
return motion.EaseInOut(progress)
8159
case easingEaseOut:
8260
// Computed below, shared with the unknown-name fallback.
8361
}

internal/adapter/overlay/linux/overlay_shared_cgo.go

Lines changed: 42 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/y3owk1n/neru/internal/adapter/overlay/render/badge"
1313
gridcomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/grid"
1414
hintscomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/hints"
15+
"github.com/y3owk1n/neru/internal/adapter/overlay/render/motion"
1516
recursivegridcomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/recursivegrid"
1617
"github.com/y3owk1n/neru/internal/domain"
1718
domainGrid "github.com/y3owk1n/neru/internal/domain/grid"
@@ -149,6 +150,14 @@ type sharedOverlay struct {
149150
lastDepth int
150151
lastRects []image.Rectangle
151152
currentAnimRects []image.Rectangle
153+
// animSettled says the transition that painted currentAnimRects reached
154+
// its last frame; one that has not is continued rather than restarted.
155+
animSettled bool
156+
// animPointer is where the last transition frame painted the virtual
157+
// pointer, and lastPointer where the last settled frame did: the pointer
158+
// rides the zoom from one of them to where the new frame puts it.
159+
animPointer image.Point
160+
lastPointer recursivegridcomponent.VirtualPointerState
152161
}
153162

154163
// The exported methods below are what the manager calls on a backend. They
@@ -564,7 +573,12 @@ func (o *sharedOverlay) drawRecursiveGridWithSubKeyPreview(
564573
duration = 50 * time.Millisecond
565574
}
566575

576+
continuing := len(o.currentAnimRects) > 0 && !o.animSettled
567577
fromRects := o.buildFromRects(cellRects, bounds)
578+
fromPointer := motion.PointerOrigin(
579+
virtualPointer.Position, o.lastPointer.Position, o.animPointer,
580+
o.lastPointer.Visible, continuing,
581+
)
568582
keyRunes := []rune(strings.ToUpper(keys))
569583
nextKeyRunes := []rune(strings.ToUpper(nextKeys))
570584

@@ -577,7 +591,7 @@ func (o *sharedOverlay) drawRecursiveGridWithSubKeyPreview(
577591
fromRects, cellRects,
578592
keyRunes, nextKeyRunes,
579593
nextDims,
580-
style, virtualPointer,
594+
style, virtualPointer, fromPointer, continuing,
581595
duration, animStop, animDone,
582596
)
583597
} else {
@@ -591,6 +605,7 @@ func (o *sharedOverlay) drawRecursiveGridWithSubKeyPreview(
591605
o.hasLast = true
592606
o.lastBounds = bounds
593607
o.lastDepth = depth
608+
o.lastPointer = virtualPointer
594609
o.lastRects = make([]image.Rectangle, len(cellRects))
595610
copy(o.lastRects, cellRects)
596611
}
@@ -899,8 +914,8 @@ func (o *sharedOverlay) startMouseActionAnimation(
899914
}
900915

901916
eased := applyEasing(style.Easing, rawProgress)
902-
scale := max(lerp(style.StartScale, style.EndScale, eased), 0)
903-
opacity := lerp(style.StartOpacity, style.EndOpacity, eased)
917+
scale := max(motion.Lerp(style.StartScale, style.EndScale, eased), 0)
918+
opacity := motion.Lerp(style.StartOpacity, style.EndOpacity, eased)
904919
diameter := baseSize * scale
905920
rect := mouseActionIndicatorRect(point, diameter)
906921
fill := applyOpacity(fillBase, opacity)
@@ -1027,57 +1042,14 @@ func (o *sharedOverlay) cancelAnimation() {
10271042
}
10281043
}
10291044

1030-
//nolint:mnd,varnamelen
1045+
// buildFromRects answers where each cell of the new depth starts its zoom
1046+
// (motion.TransitionOrigins), from the cells a running transition was
1047+
// interrupted on or the ones the last depth drew.
10311048
func (o *sharedOverlay) buildFromRects(
10321049
toRects []image.Rectangle,
10331050
bounds image.Rectangle,
10341051
) []image.Rectangle {
1035-
if len(o.currentAnimRects) == len(toRects) {
1036-
from := make([]image.Rectangle, len(o.currentAnimRects))
1037-
copy(from, o.currentAnimRects)
1038-
1039-
return from
1040-
}
1041-
1042-
if len(o.lastRects) == len(toRects) {
1043-
from := make([]image.Rectangle, len(o.lastRects))
1044-
copy(from, o.lastRects)
1045-
1046-
return from
1047-
}
1048-
1049-
if o.lastBounds.Empty() {
1050-
from := make([]image.Rectangle, len(toRects))
1051-
for idx, rect := range toRects {
1052-
cx := rect.Min.X + rect.Dx()/2
1053-
cy := rect.Min.Y + rect.Dy()/2
1054-
from[idx] = image.Rect(cx, cy, cx, cy)
1055-
}
1056-
1057-
return from
1058-
}
1059-
1060-
fromBounds := o.lastBounds
1061-
fw := float64(fromBounds.Dx())
1062-
fh := float64(fromBounds.Dy())
1063-
dw := float64(bounds.Dx())
1064-
dh := float64(bounds.Dy())
1065-
1066-
from := make([]image.Rectangle, len(toRects))
1067-
for idx, rect := range toRects {
1068-
nx := (float64(rect.Min.X+rect.Dx()/2) - float64(bounds.Min.X)) / dw
1069-
ny := (float64(rect.Min.Y+rect.Dy()/2) - float64(bounds.Min.Y)) / dh
1070-
cx := int(float64(fromBounds.Min.X) + nx*fw)
1071-
cy := int(float64(fromBounds.Min.Y) + ny*fh)
1072-
rw := rect.Dx()
1073-
rh := rect.Dy()
1074-
from[idx] = image.Rect(
1075-
cx-rw/2, cy-rh/2,
1076-
cx+rw/2, cy+rh/2,
1077-
)
1078-
}
1079-
1080-
return from
1052+
return motion.TransitionOrigins(toRects, bounds, o.currentAnimRects, o.lastRects, o.lastBounds)
10811053
}
10821054

10831055
func (o *sharedOverlay) startGridAnimation(
@@ -1086,38 +1058,35 @@ func (o *sharedOverlay) startGridAnimation(
10861058
nextDims domain.GridDimensions,
10871059
style recursivegridcomponent.Style,
10881060
virtualPointer recursivegridcomponent.VirtualPointerState,
1061+
fromPointer image.Point,
1062+
continuing bool,
10891063
duration time.Duration,
10901064
stopCh chan struct{},
10911065
doneCh chan struct{},
10921066
) {
10931067
o.srf.syncBeforeAnimation()
10941068

10951069
startTime := time.Now()
1070+
o.animSettled = false
10961071

1097-
renderFrame := func(rawProgress float64) {
1098-
if rawProgress >= 1.0 {
1099-
rawProgress = 1.0
1100-
}
1072+
// Called under renderMu. The progress is read here rather than before the
1073+
// lock was taken, so a frame that waited on it paints where the cells are
1074+
// now rather than where they were when it was scheduled.
1075+
renderFrame := func() float64 {
1076+
rawProgress := min(float64(time.Since(startTime))/float64(duration), 1)
1077+
progress := motion.Eased(rawProgress, continuing)
11011078

1102-
progress := easeInOut(rawProgress)
1103-
1104-
interpCells := make([]image.Rectangle, len(toRects))
1105-
for i := range toRects {
1106-
src := fromRects[i]
1107-
dst := toRects[i]
1108-
interpCells[i] = image.Rect(
1109-
int(lerp(float64(src.Min.X), float64(dst.Min.X), progress)),
1110-
int(lerp(float64(src.Min.Y), float64(dst.Min.Y), progress)),
1111-
int(lerp(float64(src.Max.X), float64(dst.Max.X), progress)),
1112-
int(lerp(float64(src.Max.Y), float64(dst.Max.Y), progress)),
1113-
)
1114-
}
1079+
interpCells := motion.LerpRects(fromRects, toRects, progress)
1080+
pointer := virtualPointer
1081+
pointer.Position = motion.LerpPoint(fromPointer, virtualPointer.Position, progress)
11151082

11161083
if !o.srf.beginFrame() {
1117-
return
1084+
return rawProgress
11181085
}
11191086

11201087
o.currentAnimRects = interpCells
1088+
o.animPointer = pointer.Position
1089+
o.animSettled = rawProgress >= 1
11211090

11221091
o.srf.clearFrame()
11231092
o.drawFrame(
@@ -1126,8 +1095,10 @@ func (o *sharedOverlay) startGridAnimation(
11261095
nextKeyRunes,
11271096
nextDims,
11281097
style,
1129-
virtualPointer,
1098+
pointer,
11301099
)
1100+
1101+
return rawProgress
11311102
}
11321103

11331104
go func() {
@@ -1151,13 +1122,6 @@ func (o *sharedOverlay) startGridAnimation(
11511122
default:
11521123
}
11531124

1154-
elapsed := time.Since(startTime)
1155-
1156-
rawProgress := float64(elapsed) / float64(duration)
1157-
if rawProgress >= 1.0 {
1158-
rawProgress = 1.0
1159-
}
1160-
11611125
renderStart := time.Now()
11621126

11631127
renderMu := o.renderMu
@@ -1176,13 +1140,13 @@ func (o *sharedOverlay) startGridAnimation(
11761140
}
11771141
}
11781142

1179-
renderFrame(rawProgress)
1143+
rawProgress := renderFrame()
11801144

11811145
if renderMu != nil {
11821146
renderMu.Unlock()
11831147
}
11841148

1185-
if rawProgress >= 1.0 {
1149+
if rawProgress >= 1 {
11861150
return
11871151
}
11881152

0 commit comments

Comments
 (0)