From 7c6d082585205e37b6ec0c30837aeb35a389467e Mon Sep 17 00:00:00 2001 From: Kyle Wong <62775956+y3owk1n@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:23:18 +0800 Subject: [PATCH 1/3] refactor(overlay): share the grid transition curve between backends The smoothstep easing, the per-cell interpolation and the geometry that decides where a depth change zooms from lived in the Linux cgo backend. Move them into an untagged render/motion package so the Windows backend can drive the same transition from the same arithmetic rather than a copy, and have the Linux backend delegate to it. No behaviour changes. --- .../adapter/overlay/linux/animation_cgo.go | 28 +--- .../overlay/linux/overlay_shared_cgo.go | 70 ++-------- .../adapter/overlay/render/motion/motion.go | 127 ++++++++++++++++++ .../overlay/render/motion/motion_test.go | 114 ++++++++++++++++ justfile | 1 + 5 files changed, 253 insertions(+), 87 deletions(-) create mode 100644 internal/adapter/overlay/render/motion/motion.go create mode 100644 internal/adapter/overlay/render/motion/motion_test.go diff --git a/internal/adapter/overlay/linux/animation_cgo.go b/internal/adapter/overlay/linux/animation_cgo.go index 5bf1cd42e..df0c3fa67 100644 --- a/internal/adapter/overlay/linux/animation_cgo.go +++ b/internal/adapter/overlay/linux/animation_cgo.go @@ -6,6 +6,8 @@ import ( "image" "math" "time" + + "github.com/y3owk1n/neru/internal/adapter/overlay/render/motion" ) const ( @@ -34,30 +36,6 @@ const ( alphaRoundBias = 0.5 ) -// easeInOut applies a smoothstep ease-in-out interpolation. -// Matches the visual feel of kCAMediaTimingFunctionEaseInEaseOut on macOS. -func easeInOut(progress float64) float64 { - const ( - smoothStep3 = 3 - smoothStep2 = 2 - ) - - if progress <= 0 { - return 0 - } - - if progress >= 1 { - return 1 - } - - return progress * progress * (smoothStep3 - smoothStep2*progress) -} - -// lerp linearly interpolates between a and b by t. -func lerp(a, b, t float64) float64 { - return a + (b-a)*t -} - // applyEasing maps a linear progress in [0,1] through the named easing curve, // matching the easing names accepted by the mouse-action-indicator config // (linear, ease_in, ease_out, ease_in_out). Unknown names fall back to @@ -77,7 +55,7 @@ func applyEasing(easing string, progress float64) float64 { case easingEaseIn: return progress * progress * progress case easingEaseInOut: - return easeInOut(progress) + return motion.EaseInOut(progress) case easingEaseOut: // Computed below, shared with the unknown-name fallback. } diff --git a/internal/adapter/overlay/linux/overlay_shared_cgo.go b/internal/adapter/overlay/linux/overlay_shared_cgo.go index 022a4fd9b..034a1923b 100644 --- a/internal/adapter/overlay/linux/overlay_shared_cgo.go +++ b/internal/adapter/overlay/linux/overlay_shared_cgo.go @@ -12,6 +12,7 @@ import ( "github.com/y3owk1n/neru/internal/adapter/overlay/render/badge" gridcomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/grid" hintscomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/hints" + "github.com/y3owk1n/neru/internal/adapter/overlay/render/motion" recursivegridcomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/recursivegrid" "github.com/y3owk1n/neru/internal/domain" domainGrid "github.com/y3owk1n/neru/internal/domain/grid" @@ -899,8 +900,8 @@ func (o *sharedOverlay) startMouseActionAnimation( } eased := applyEasing(style.Easing, rawProgress) - scale := max(lerp(style.StartScale, style.EndScale, eased), 0) - opacity := lerp(style.StartOpacity, style.EndOpacity, eased) + scale := max(motion.Lerp(style.StartScale, style.EndScale, eased), 0) + opacity := motion.Lerp(style.StartOpacity, style.EndOpacity, eased) diameter := baseSize * scale rect := mouseActionIndicatorRect(point, diameter) fill := applyOpacity(fillBase, opacity) @@ -1027,57 +1028,14 @@ func (o *sharedOverlay) cancelAnimation() { } } -//nolint:mnd,varnamelen +// buildFromRects answers where each cell of the new depth starts its zoom +// (motion.TransitionOrigins), from the cells a running transition was +// interrupted on or the ones the last depth drew. func (o *sharedOverlay) buildFromRects( toRects []image.Rectangle, bounds image.Rectangle, ) []image.Rectangle { - if len(o.currentAnimRects) == len(toRects) { - from := make([]image.Rectangle, len(o.currentAnimRects)) - copy(from, o.currentAnimRects) - - return from - } - - if len(o.lastRects) == len(toRects) { - from := make([]image.Rectangle, len(o.lastRects)) - copy(from, o.lastRects) - - return from - } - - if o.lastBounds.Empty() { - from := make([]image.Rectangle, len(toRects)) - for idx, rect := range toRects { - cx := rect.Min.X + rect.Dx()/2 - cy := rect.Min.Y + rect.Dy()/2 - from[idx] = image.Rect(cx, cy, cx, cy) - } - - return from - } - - fromBounds := o.lastBounds - fw := float64(fromBounds.Dx()) - fh := float64(fromBounds.Dy()) - dw := float64(bounds.Dx()) - dh := float64(bounds.Dy()) - - from := make([]image.Rectangle, len(toRects)) - for idx, rect := range toRects { - nx := (float64(rect.Min.X+rect.Dx()/2) - float64(bounds.Min.X)) / dw - ny := (float64(rect.Min.Y+rect.Dy()/2) - float64(bounds.Min.Y)) / dh - cx := int(float64(fromBounds.Min.X) + nx*fw) - cy := int(float64(fromBounds.Min.Y) + ny*fh) - rw := rect.Dx() - rh := rect.Dy() - from[idx] = image.Rect( - cx-rw/2, cy-rh/2, - cx+rw/2, cy+rh/2, - ) - } - - return from + return motion.TransitionOrigins(toRects, bounds, o.currentAnimRects, o.lastRects, o.lastBounds) } func (o *sharedOverlay) startGridAnimation( @@ -1099,19 +1057,7 @@ func (o *sharedOverlay) startGridAnimation( rawProgress = 1.0 } - progress := easeInOut(rawProgress) - - interpCells := make([]image.Rectangle, len(toRects)) - for i := range toRects { - src := fromRects[i] - dst := toRects[i] - interpCells[i] = image.Rect( - int(lerp(float64(src.Min.X), float64(dst.Min.X), progress)), - int(lerp(float64(src.Min.Y), float64(dst.Min.Y), progress)), - int(lerp(float64(src.Max.X), float64(dst.Max.X), progress)), - int(lerp(float64(src.Max.Y), float64(dst.Max.Y), progress)), - ) - } + interpCells := motion.LerpRects(fromRects, toRects, motion.EaseInOut(rawProgress)) if !o.srf.beginFrame() { return diff --git a/internal/adapter/overlay/render/motion/motion.go b/internal/adapter/overlay/render/motion/motion.go new file mode 100644 index 000000000..9f69d5f44 --- /dev/null +++ b/internal/adapter/overlay/render/motion/motion.go @@ -0,0 +1,127 @@ +// Package motion holds the platform-neutral arithmetic of the recursive-grid +// transition: the easing curve, the interpolation between the cells of two +// depths, and where a transition starts from. The Linux (Cairo) and Windows +// (Direct2D / GDI) backends both drive a frame loop from here, so a depth +// change zooms the same way on each; macOS hands the same curve to +// CoreAnimation as kCAMediaTimingFunctionEaseInEaseOut. +package motion + +import ( + "image" + "time" +) + +const ( + // FramesPerSecond is the rate a software-driven transition renders at. + FramesPerSecond = 120 + // FrameInterval is the time budget of one frame at FramesPerSecond. + FrameInterval = time.Second / FramesPerSecond + + smoothStep3 = 3 + smoothStep2 = 2 + halfDivisor = 2 +) + +// EaseInOut applies a smoothstep ease-in-out interpolation to a progress in +// [0,1], clamping outside it. +func EaseInOut(progress float64) float64 { + if progress <= 0 { + return 0 + } + + if progress >= 1 { + return 1 + } + + return progress * progress * (smoothStep3 - smoothStep2*progress) +} + +// Lerp linearly interpolates between a and b by t. +func Lerp(a, b, t float64) float64 { + return a + (b-a)*t +} + +// LerpRects interpolates each rectangle of from towards the one at the same +// index of to by progress. The slices are expected to be the same length; +// extra entries in from are ignored and missing ones are taken as already +// arrived. +func LerpRects(from, to []image.Rectangle, progress float64) []image.Rectangle { + out := make([]image.Rectangle, len(to)) + + for idx, dst := range to { + if idx >= len(from) { + out[idx] = dst + + continue + } + + src := from[idx] + out[idx] = image.Rect( + int(Lerp(float64(src.Min.X), float64(dst.Min.X), progress)), + int(Lerp(float64(src.Min.Y), float64(dst.Min.Y), progress)), + int(Lerp(float64(src.Max.X), float64(dst.Max.X), progress)), + int(Lerp(float64(src.Max.Y), float64(dst.Max.Y), progress)), + ) + } + + return out +} + +// TransitionOrigins answers where each cell of the new depth starts its zoom. +// +// The candidates, in order: the cells a previous transition was interrupted +// on, so a fast keystroke continues from where the screen is; the cells the +// last depth drew, when it had the same count; each cell collapsed to its own +// center when nothing was drawn before; and otherwise each new cell placed at +// its relative position inside the bounds the last depth covered, which is +// what makes a drill-down read as a zoom into the picked cell. +func TransitionOrigins( + toRects []image.Rectangle, + bounds image.Rectangle, + interruptedRects, lastRects []image.Rectangle, + lastBounds image.Rectangle, +) []image.Rectangle { + from := make([]image.Rectangle, len(toRects)) + + if len(interruptedRects) == len(toRects) { + copy(from, interruptedRects) + + return from + } + + if len(lastRects) == len(toRects) { + copy(from, lastRects) + + return from + } + + if lastBounds.Empty() { + for idx, rect := range toRects { + cx := rect.Min.X + rect.Dx()/halfDivisor + cy := rect.Min.Y + rect.Dy()/halfDivisor + from[idx] = image.Rect(cx, cy, cx, cy) + } + + return from + } + + lastWidth := float64(lastBounds.Dx()) + lastHeight := float64(lastBounds.Dy()) + width := float64(bounds.Dx()) + height := float64(bounds.Dy()) + + for idx, rect := range toRects { + relX := (float64(rect.Min.X+rect.Dx()/halfDivisor) - float64(bounds.Min.X)) / width + relY := (float64(rect.Min.Y+rect.Dy()/halfDivisor) - float64(bounds.Min.Y)) / height + centerX := int(float64(lastBounds.Min.X) + relX*lastWidth) + centerY := int(float64(lastBounds.Min.Y) + relY*lastHeight) + halfWidth := rect.Dx() / halfDivisor + halfHeight := rect.Dy() / halfDivisor + from[idx] = image.Rect( + centerX-halfWidth, centerY-halfHeight, + centerX+halfWidth, centerY+halfHeight, + ) + } + + return from +} diff --git a/internal/adapter/overlay/render/motion/motion_test.go b/internal/adapter/overlay/render/motion/motion_test.go new file mode 100644 index 000000000..c40d0b6fc --- /dev/null +++ b/internal/adapter/overlay/render/motion/motion_test.go @@ -0,0 +1,114 @@ +package motion_test + +import ( + "image" + "testing" + + "github.com/y3owk1n/neru/internal/adapter/overlay/render/motion" +) + +func TestEaseInOut_ClampsAndIsSymmetric(t *testing.T) { + t.Parallel() + + const eps = 1e-9 + + tests := []struct { + name string + progress float64 + want float64 + }{ + {"below zero clamps", -1, 0}, + {"zero", 0, 0}, + {"quarter eases in", 0.25, 0.15625}, + {"midpoint", 0.5, 0.5}, + {"three quarters eases out", 0.75, 0.84375}, + {"one", 1, 1}, + {"above one clamps", 2, 1}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got := motion.EaseInOut(testCase.progress) + if diff := got - testCase.want; diff > eps || diff < -eps { + t.Errorf("EaseInOut(%v) = %v, want %v", testCase.progress, got, testCase.want) + } + }) + } +} + +func TestLerpRects_EndpointsAndMidpoint(t *testing.T) { + t.Parallel() + + from := []image.Rectangle{image.Rect(0, 0, 100, 100)} + target := []image.Rectangle{image.Rect(50, 50, 150, 250)} + + if got := motion.LerpRects(from, target, 0)[0]; got != from[0] { + t.Fatalf("t=0: got %v, want %v", got, from[0]) + } + + if got := motion.LerpRects(from, target, 1)[0]; got != target[0] { + t.Fatalf("t=1: got %v, want %v", got, target[0]) + } + + want := image.Rect(25, 25, 125, 175) + if got := motion.LerpRects(from, target, 0.5)[0]; got != want { + t.Fatalf("t=0.5: got %v, want %v", got, want) + } +} + +func TestLerpRects_MissingOriginIsAlreadyArrived(t *testing.T) { + t.Parallel() + + target := []image.Rectangle{image.Rect(0, 0, 10, 10), image.Rect(10, 0, 20, 10)} + got := motion.LerpRects(target[:1], target, 0.5) + + if got[1] != target[1] { + t.Fatalf("cell without an origin: got %v, want %v", got[1], target[1]) + } +} + +func TestTransitionOrigins_PrefersInterruptedThenLastThenGeometry(t *testing.T) { + t.Parallel() + + bounds := image.Rect(0, 0, 200, 200) + target := []image.Rectangle{ + image.Rect(0, 0, 100, 100), image.Rect(100, 0, 200, 100), + image.Rect(0, 100, 100, 200), image.Rect(100, 100, 200, 200), + } + interrupted := []image.Rectangle{ + image.Rect(1, 1, 2, 2), image.Rect(3, 3, 4, 4), + image.Rect(5, 5, 6, 6), image.Rect(7, 7, 8, 8), + } + last := []image.Rectangle{ + image.Rect(9, 9, 10, 10), image.Rect(11, 11, 12, 12), + image.Rect(13, 13, 14, 14), image.Rect(15, 15, 16, 16), + } + + got := motion.TransitionOrigins(target, bounds, interrupted, last, bounds) + if got[0] != interrupted[0] { + t.Fatalf("interrupted cells win: got %v, want %v", got[0], interrupted[0]) + } + + got = motion.TransitionOrigins(target, bounds, nil, last, bounds) + if got[0] != last[0] { + t.Fatalf("last cells next: got %v, want %v", got[0], last[0]) + } + + got = motion.TransitionOrigins(target, bounds, nil, nil, image.Rectangle{}) + if want := image.Rect(50, 50, 50, 50); got[0] != want { + t.Fatalf("no previous depth collapses to the center: got %v, want %v", got[0], want) + } + + // Drilling into the bottom-right quarter: each new cell starts at its + // relative spot inside the cell that was picked, at its final size. + got = motion.TransitionOrigins(target, bounds, nil, nil, image.Rect(100, 100, 200, 200)) + if want := image.Rect(75, 75, 175, 175); got[0] != want { + t.Fatalf("zoom origin: got %v, want %v", got[0], want) + } + + if want := image.Rect(125, 125, 225, 225); got[3] != want { + t.Fatalf("zoom origin: got %v, want %v", got[3], want) + } +} diff --git a/justfile b/justfile index 59df4003d..cb5b95f1c 100644 --- a/justfile +++ b/justfile @@ -288,6 +288,7 @@ test-foundation: ./internal/flagref ./internal/supportref \ ./internal/adapter/logger \ ./internal/adapter/overlay/render/badge \ + ./internal/adapter/overlay/render/motion \ ./internal/adapter/platform/compositorcli \ ./internal/adapter/platform/fontcache \ ./internal/adapter/platform/fontgeneric \ From f0edac7fce41938cd9c301a3dd53fc8c07ae1bef Mon Sep 17 00:00:00 2001 From: Kyle Wong <62775956+y3owk1n@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:23:18 +0800 Subject: [PATCH 2/3] feat(windows): grid and recursive-grid transition animation A recursive-grid depth change on Windows now zooms the cells from where the previous depth left them to where the new one puts them, over recursive_grid.animation.duration_ms and on the same smoothstep curve macOS and Linux use. A goroutine paces the frames and each one is handed to the overlay UI thread through the existing flush path, so painting and presenting stay on that thread and frames the thread has not reached coalesce. A zero duration, or the animation switched off, paints the new depth immediately; any other draw that takes the surface cancels a running transition first. Closes #1562 --- docs/CROSS_PLATFORM.md | 17 +- internal/adapter/overlay/AGENTS.md | 2 +- internal/adapter/overlay/windows/features.go | 60 ++++++- internal/adapter/overlay/windows/manager.go | 34 +++- internal/adapter/overlay/windows/overlay.go | 39 ++++- .../adapter/overlay/windows/transition.go | 130 +++++++++++++++ .../transition_integration_windows_test.go | 149 ++++++++++++++++++ internal/config/platform_support.go | 7 +- 8 files changed, 405 insertions(+), 33 deletions(-) create mode 100644 internal/adapter/overlay/windows/transition.go create mode 100644 internal/adapter/overlay/windows/transition_integration_windows_test.go diff --git a/docs/CROSS_PLATFORM.md b/docs/CROSS_PLATFORM.md index 7fbb61e11..70c19ecc7 100644 --- a/docs/CROSS_PLATFORM.md +++ b/docs/CROSS_PLATFORM.md @@ -923,7 +923,7 @@ important thing to know before touching overlay code: | Animation | macOS | Linux X11 / Wayland | Windows | | ---------------------------- | ------------------------------------ | ---------------------------------- | ---------------------------------- | -| **Grid transition** | CoreAnimation, ease-in-out @120Hz | goroutine, smoothstep @120fps | ❌ | +| **Grid transition** | CoreAnimation, ease-in-out @120Hz | goroutine, smoothstep @120fps | goroutine, smoothstep @120fps, presented on the UI thread | | **Mouse action indicator** | `CABasicAnimation` (scale + opacity) | goroutine, scale + opacity @120fps | goroutine, cubic easing @60fps | | **Smooth cursor** | ✅ stepped linear interpolation | ✅ stepped linear interpolation | ❌ | | **Smooth scroll** | ✅ ease-out cubic | ❌ | ❌ | @@ -946,10 +946,10 @@ discovery rather than the mode itself. | **Hints** | Search input badge | ✅ | ✅ Cairo badge | ✅ | | **Hints** | Label arrow / tail | ✅ NSBezierPath | ✅ Cairo triangle | ✅ sampled triangle, see below | | **Hints** | Label placement | ✅ top / center / bottom | ✅ top / center / bottom | ✅ top / center / bottom | -| **Grid** | Transition animation | ✅ | ✅ | ❌ | +| **Grid** | Transition animation | ✅ | ✅ | ✅ | | **Grid** | Virtual pointer indicator | ✅ | ✅ | ✅ | | **Grid** | What an open subgrid shows | ✅ the subgrid alone | ✅ the subgrid alone | ⚠️ the parent cells return under it on the next repaint | -| **Recursive grid**| Transition animation | ✅ | ✅ | ❌ | +| **Recursive grid**| Transition animation | ✅ | ✅ | ✅ | | **Recursive grid**| Virtual pointer indicator | ✅ | ✅ | ✅ | | **Recursive grid**| Sub-key preview | ✅ mini-grid of next keys | ✅ mini-grid of next keys | ✅ mini-grid of next keys | | **Scroll** | Smooth scroll animation | ✅ | ✅ (X11: whole notches) | ❌ | @@ -1078,8 +1078,6 @@ green in every cell while an option means nothing, which is exactly how | `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 | | `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 | | `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 | -| `recursive_grid.animation.enabled` | option | ✅ | ✅ | ❌ | the Windows overlay backend has no grid transition animation | -| `recursive_grid.animation.duration_ms` | option | ✅ | ✅ | ❌ | the Windows overlay backend has no grid transition animation | | `smooth_cursor.move_mouse_enabled` | option | ✅ | ✅ | ❌ | cursor movement is not animated on Windows | | `smooth_cursor.steps` | option | ✅ | ✅ | ❌ | cursor movement is not animated on Windows | | `smooth_cursor.max_duration` | option | ✅ | ✅ | ❌ | cursor movement is not animated on Windows | @@ -1186,12 +1184,11 @@ working, which is exactly why the build exists. **Windows** 1. Native notifications — no toast support -2. Grid and recursive-grid transition animation — not implemented -3. Smooth cursor and smooth scroll animation — not implemented -4. Font resolution — alias mapping only, no system font enumeration -5. `neru services` — every subcommand returns `CodeNotSupported`, where macOS +2. Smooth cursor and smooth scroll animation — not implemented +3. Font resolution — alias mapping only, no system font enumeration +4. `neru services` — every subcommand returns `CodeNotSupported`, where macOS installs a launchd agent and Linux a systemd user unit -6. IPC endpoint, client side — the daemon's endpoint is scoped to one user on +5. IPC endpoint, client side — the daemon's endpoint is scoped to one user on every platform, but only the Unix client checks that for itself before connecting. A named pipe carries no ownership a client can read without opening it, so the Windows CLI trusts the name it derives from its own SID. diff --git a/internal/adapter/overlay/AGENTS.md b/internal/adapter/overlay/AGENTS.md index 3b63e469f..fa10f1e53 100644 --- a/internal/adapter/overlay/AGENTS.md +++ b/internal/adapter/overlay/AGENTS.md @@ -3,7 +3,7 @@ These rules fail silently at runtime, not at compile time; read before editing. - **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`). -- **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`. +- **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`. - **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. - **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. - **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`). diff --git a/internal/adapter/overlay/windows/features.go b/internal/adapter/overlay/windows/features.go index 412e9a47e..ba3a71d10 100644 --- a/internal/adapter/overlay/windows/features.go +++ b/internal/adapter/overlay/windows/features.go @@ -5,9 +5,11 @@ package windows import ( "image" "strings" + "time" "github.com/y3owk1n/neru/internal/adapter/overlay/render/badge" hintscomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/hints" + "github.com/y3owk1n/neru/internal/adapter/overlay/render/motion" recursivegridcomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/recursivegrid" "github.com/y3owk1n/neru/internal/domain" "github.com/y3owk1n/neru/internal/domain/recursivegrid" @@ -53,6 +55,7 @@ func (o *winOverlay) DrawHints( } // Hints own the surface; drop any cached grid so Show() does not redraw it. + o.forgetTransition() o.cachedGrid = nil o.currentSubgrid = nil o.suppressDraw = false @@ -217,22 +220,29 @@ func outsetHintArrow(arrow badge.HintArrow, width int) badge.HintArrow { // as a mini-grid of the keys that would select its sub-cells. They are zero when // the region can no longer be divided, and then nothing is previewed. // -// The dimensions arrive as domain.GridDimensions rather than as a column count -// beside a row count so that this backend has no pair to transpose on its way -// to ComputeGridCells (#1313). +// A depth other than the one last drawn zooms the cells there over +// animDuration when animEnabled says so (transition.go); a zero duration, a +// first draw and a redraw at the same depth paint in place. The dimensions +// arrive as domain.GridDimensions rather than as a column count beside a row +// count so that this backend has no pair to transpose on its way to +// ComputeGridCells (#1313). func (o *winOverlay) DrawRecursiveGrid( bounds image.Rectangle, + depth int, keys string, dims domain.GridDimensions, nextKeys string, nextDims domain.GridDimensions, style recursivegridcomponent.Style, virtualPointer recursivegridcomponent.VirtualPointerState, + animEnabled bool, + animDuration time.Duration, ) { if o == nil { return } + o.cancelTransition() o.ensureWindowForDraw() if o.window == nil { @@ -251,13 +261,51 @@ func (o *winOverlay) DrawRecursiveGrid( o.currentSubgrid = nil o.suppressDraw = false - o.Clear() - keyRunes := []rune(strings.ToUpper(keys)) nextKeyRunes := []rune(strings.ToUpper(nextKeys)) + cellRects := recursivegrid.ComputeGridCells(bounds, dims) + + shouldAnimate := animEnabled && animDuration > 0 && o.hasLast && + depth != o.lastDepth && !o.lastBounds.Empty() + + if shouldAnimate { + o.startTransition(transitionPlan{ + fromRects: motion.TransitionOrigins( + cellRects, bounds, o.animRects, o.lastRects, o.lastBounds, + ), + toRects: cellRects, + keyRunes: keyRunes, + nextKeyRunes: nextKeyRunes, + nextDims: nextDims, + style: style, + pointer: virtualPointer, + duration: animDuration, + }) + } else { + o.animRects = nil + o.paintRecursiveGrid(cellRects, keyRunes, nextKeyRunes, nextDims, style, virtualPointer) + } + + o.hasLast = true + o.lastDepth = depth + o.lastBounds = bounds + o.lastRects = cellRects +} + +// paintRecursiveGrid paints one whole recursive-grid frame, the cells at the +// rectangles given: the settled layout of a depth, or an interpolated step of +// the zoom between two. +func (o *winOverlay) paintRecursiveGrid( + cellRects []image.Rectangle, + keyRunes, nextKeyRunes []rune, + nextDims domain.GridDimensions, + style recursivegridcomponent.Style, + virtualPointer recursivegridcomponent.VirtualPointerState, +) { + o.Clear() + drawSubPreview := style.PreviewsNextDepth(len(nextKeyRunes), nextDims) - cellRects := recursivegrid.ComputeGridCells(bounds, dims) for idx, cell := range cellRects { if style.HighlightColorARGB() != 0 { o.window.FillRect(cell, style.HighlightColorARGB()) diff --git a/internal/adapter/overlay/windows/manager.go b/internal/adapter/overlay/windows/manager.go index 014cb84c1..9bb042da4 100644 --- a/internal/adapter/overlay/windows/manager.go +++ b/internal/adapter/overlay/windows/manager.go @@ -66,11 +66,13 @@ var ( // NewOverlayManager creates a new overlay Manager. func NewOverlayManager(logger *zap.Logger) *Manager { - return &Manager{ + mgr := &Manager{ Base: manager.NewBase(logger), logger: logger, - win: newWinOverlay(logger), } + mgr.win = newWinOverlay(logger, &mgr.renderMu) + + return mgr } // Get returns the global overlay Manager. @@ -138,6 +140,7 @@ func (m *Manager) Clear() { defer m.renderMu.Unlock() if m.win != nil { + m.win.forgetTransition() m.win.Clear() } } @@ -769,12 +772,13 @@ func (m *Manager) DrawGrid(gridValue *domainGrid.Grid, input string, style grid. // DrawRecursiveGrid draws the recursive-grid overlay on the Windows overlay window. // // The next-depth keys and dimensions are handed on to the draw, which previews -// them as a mini-grid inside each cell. The depth is not: this backend has no -// transition animation, so nothing here compares the depth against the last one -// drawn (docs/CROSS_PLATFORM.md owns that status). +// them as a mini-grid inside each cell. So is the depth: the surface compares +// it against the last one drawn and zooms between them when +// recursive_grid.animation says to (transition.go), the way the Linux +// backends do. func (m *Manager) DrawRecursiveGrid( bounds image.Rectangle, - _ int, + depth int, keys string, dims domain.GridDimensions, nextKeys string, @@ -794,9 +798,23 @@ func (m *Manager) DrawRecursiveGrid( ) } + var ( + animEnabled bool + animDuration time.Duration + ) + + if m.RecursiveGridOverlay() != nil { + animCfg := m.RecursiveGridOverlay().Config().Animation + animEnabled = animCfg.Enabled + animDuration = time.Duration(animCfg.DurationMS) * time.Millisecond + } + // Shared activation may draw before the resize; enforce monitor bounds here. m.win.Resize() - m.win.DrawRecursiveGrid(bounds, keys, dims, nextKeys, nextDims, style, virtualPointer) + m.win.DrawRecursiveGrid( + bounds, depth, keys, dims, nextKeys, nextDims, style, virtualPointer, + animEnabled, animDuration, + ) return nil } @@ -1071,7 +1089,7 @@ func (m *Manager) ensureWinOverlayLocked() { m.win = nil } - m.win = newWinOverlay(m.logger) + m.win = newWinOverlay(m.logger, &m.renderMu) if m.win == nil && m.logger != nil { m.logger.Error("Windows overlay window is unavailable; grid overlay cannot render") } diff --git a/internal/adapter/overlay/windows/overlay.go b/internal/adapter/overlay/windows/overlay.go index 677c7b47d..0f8704eec 100644 --- a/internal/adapter/overlay/windows/overlay.go +++ b/internal/adapter/overlay/windows/overlay.go @@ -3,8 +3,10 @@ package windows import ( + "context" "image" "strings" + "sync" "unsafe" "go.uber.org/zap" @@ -25,8 +27,11 @@ const ( ) type winOverlay struct { - window *winplatform.OverlayWindow - logger *zap.Logger + window *winplatform.OverlayWindow + logger *zap.Logger + // renderMu is the manager's lock, which every draw here runs under; the + // transition goroutine takes it per frame (transition.go). + renderMu *sync.Mutex currentPrefix string hideUnmatched bool currentSubgrid *domainGrid.Cell @@ -49,9 +54,22 @@ type winOverlay struct { // accepted with is what is on screen, and re-resolving would hand a // void-returning redraw a refusal it has nowhere to report. lastHintOffset badge.HintOffset + + // The depth the recursive grid last drew, which is what a depth change + // zooms from (transition.go). hasLast says whether there is one; a clear, + // a resize or another mode's draw forgets it. + hasLast bool + lastDepth int + lastBounds image.Rectangle + lastRects []image.Rectangle + // animRects are the cells the last transition frame painted, so a depth + // change arriving mid-zoom continues from the screen rather than jumping. + animRects []image.Rectangle + transitionCancel context.CancelFunc + transitionDone chan struct{} } -func newWinOverlay(logger *zap.Logger) *winOverlay { +func newWinOverlay(logger *zap.Logger, renderMu *sync.Mutex) *winOverlay { window, err := winplatform.NewOverlayWindow() if err != nil { if logger != nil { @@ -93,7 +111,7 @@ func newWinOverlay(logger *zap.Logger) *winOverlay { }) } - return &winOverlay{window: window, logger: logger} + return &winOverlay{window: window, logger: logger, renderMu: renderMu} } func (o *winOverlay) Healthy() bool { @@ -154,6 +172,7 @@ func (o *winOverlay) Hide() { return } + o.cancelTransition() o.suppressDraw = true o.currentSubgrid = nil o.gridPointer = recursivegridcomponent.VirtualPointerState{} @@ -178,6 +197,7 @@ func (o *winOverlay) ClearCache() { return } + o.forgetTransition() o.cachedGrid = nil o.cachedStyle = gridcomponent.Style{} o.currentPrefix = "" @@ -193,13 +213,23 @@ func (o *winOverlay) Resize() { return } + before := o.window.Bounds() + err := o.window.ResizeToActiveScreen() if err != nil && o.logger != nil { o.logger.Warn("failed to resize Windows overlay", zap.Error(err)) } + + // Every recursive-grid draw resizes first, so only a window that moved + // forgets the depth it drew: the old cells belong to a screen that is gone. + if o.window.Bounds() != before { + o.forgetTransition() + } } func (o *winOverlay) Destroy() { + o.cancelTransition() + if o != nil && o.window != nil { o.window.Destroy() o.window = nil @@ -302,6 +332,7 @@ func (o *winOverlay) DrawGrid(gridValue *domainGrid.Grid, input string, style gr return } + o.forgetTransition() o.cachedGrid = gridValue o.cachedStyle = style o.currentPrefix = strings.ToUpper(input) diff --git a/internal/adapter/overlay/windows/transition.go b/internal/adapter/overlay/windows/transition.go new file mode 100644 index 000000000..ae0577c19 --- /dev/null +++ b/internal/adapter/overlay/windows/transition.go @@ -0,0 +1,130 @@ +//go:build windows + +package windows + +import ( + "context" + "image" + "time" + + "github.com/y3owk1n/neru/internal/adapter/overlay/render/motion" + recursivegridcomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/recursivegrid" + "github.com/y3owk1n/neru/internal/domain" +) + +// The recursive-grid depth transition on the Windows overlay window. +// +// A depth change zooms the cells from where the last depth left them to where +// the new depth puts them, on the curve the Linux Cairo path and CoreAnimation +// use (render/motion). A goroutine paces the frames; each one takes the +// manager's renderMu the way the mouse-action indicator's does, queues the +// interpolated cells and hands them to the overlay UI thread through Flush, so +// the painting and presenting stay on that thread and a frame the thread has +// not reached yet coalesces with the next. + +// transitionPlan is what one frame of a depth transition paints, resolved once +// when the transition starts so a frame reads nothing that a later draw could +// have replaced. +type transitionPlan struct { + fromRects, toRects []image.Rectangle + keyRunes []rune + nextKeyRunes []rune + nextDims domain.GridDimensions + style recursivegridcomponent.Style + pointer recursivegridcomponent.VirtualPointerState + duration time.Duration +} + +// startTransition begins painting plan over its duration. The caller holds +// renderMu, and has canceled the transition before it. +func (o *winOverlay) startTransition(plan transitionPlan) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + o.transitionCancel = cancel + o.transitionDone = done + + go o.runTransition(ctx, plan, done) +} + +func (o *winOverlay) runTransition(ctx context.Context, plan transitionPlan, done chan struct{}) { + defer close(done) + + startTime := time.Now() + + for { + rawProgress := min(float64(time.Since(startTime))/float64(plan.duration), 1) + + frameStart := time.Now() + + o.renderMu.Lock() + + // The draw that canceled this transition may have been waiting for + // the lock this frame now holds; its cancel is the last word. + select { + case <-ctx.Done(): + o.renderMu.Unlock() + + return + default: + } + + if o.window != nil && o.window.Healthy() { + o.animRects = motion.LerpRects( + plan.fromRects, plan.toRects, motion.EaseInOut(rawProgress), + ) + o.paintRecursiveGrid( + o.animRects, + plan.keyRunes, + plan.nextKeyRunes, + plan.nextDims, + plan.style, + plan.pointer, + ) + } + + if rawProgress >= 1 { + o.transitionCancel = nil + o.transitionDone = nil + } + + o.renderMu.Unlock() + + if rawProgress >= 1 { + return + } + + select { + case <-ctx.Done(): + return + case <-time.After(motion.FrameInterval - time.Since(frameStart)): + } + } +} + +// cancelTransition stops a running transition without waiting for its +// goroutine: the goroutine re-checks the cancel under renderMu before it +// paints, so a caller holding the lock has the last frame it will see on +// screen. Every draw that repaints this surface calls it first. +func (o *winOverlay) cancelTransition() { + if o == nil || o.transitionCancel == nil { + return + } + + o.transitionCancel() + o.transitionCancel = nil + o.transitionDone = nil +} + +// forgetTransition cancels a running transition and forgets the depth the +// surface last drew, so the next recursive-grid frame draws in place rather +// than zooming out of bounds that are gone: a cleared surface, a resized +// window and every other mode's draw all end here. +func (o *winOverlay) forgetTransition() { + if o == nil { + return + } + + o.cancelTransition() + o.hasLast = false + o.animRects = nil +} diff --git a/internal/adapter/overlay/windows/transition_integration_windows_test.go b/internal/adapter/overlay/windows/transition_integration_windows_test.go new file mode 100644 index 000000000..08e3a4752 --- /dev/null +++ b/internal/adapter/overlay/windows/transition_integration_windows_test.go @@ -0,0 +1,149 @@ +//go:build integration && windows + +package windows + +import ( + "image" + "sync" + "testing" + "time" + + "github.com/y3owk1n/neru/internal/adapter/overlay/render/badge" + hintscomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/hints" + recursivegridcomponent "github.com/y3owk1n/neru/internal/adapter/overlay/render/recursivegrid" + "github.com/y3owk1n/neru/internal/domain" + "github.com/y3owk1n/neru/internal/domain/recursivegrid" +) + +// Real Win32 overlay integration tests for the recursive-grid depth +// transition. They need an interactive desktop session, which CI's +// windows-latest runner has: +// go test -tags=integration ./internal/adapter/overlay/windows/... +func newTestWinOverlay(t *testing.T) (*winOverlay, *sync.Mutex) { + t.Helper() + + var renderMu sync.Mutex + + overlay := newWinOverlay(nil, &renderMu) + if overlay == nil { + t.Skip("skipping: overlay requires an interactive desktop") + } + + if !overlay.Healthy() { + overlay.Destroy() + t.Skip("skipping: overlay window is not healthy") + } + + t.Cleanup(func() { + renderMu.Lock() + overlay.Destroy() + renderMu.Unlock() + }) + + return overlay, &renderMu +} + +func drawDepth( + overlay *winOverlay, + renderMu *sync.Mutex, + bounds image.Rectangle, + depth int, + enabled bool, + duration time.Duration, +) chan struct{} { + renderMu.Lock() + defer renderMu.Unlock() + + dims := domain.GridDimensions{Cols: 2, Rows: 2} + overlay.DrawRecursiveGrid( + bounds, depth, "ABCD", dims, "ABCD", dims, + recursivegridcomponent.Style{}, recursivegridcomponent.VirtualPointerState{}, + enabled, duration, + ) + + return overlay.transitionDone +} + +func TestWinOverlayDrawRecursiveGrid_DepthChangeAnimatesForTheConfiguredDuration(t *testing.T) { + overlay, renderMu := newTestWinOverlay(t) + + screen := image.Rect(0, 0, 400, 400) + picked := image.Rect(200, 200, 400, 400) + + const duration = 80 * time.Millisecond + + if done := drawDepth(overlay, renderMu, screen, 1, true, duration); done != nil { + t.Fatal("the first draw has no depth to zoom from and must paint in place") + } + + started := time.Now() + + done := drawDepth(overlay, renderMu, picked, 2, true, duration) + if done == nil { + t.Fatal("a depth change with the animation enabled must start a transition") + } + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the transition did not finish") + } + + if elapsed := time.Since(started); elapsed < duration { + t.Fatalf("the transition finished after %v, before its %v duration", elapsed, duration) + } + + renderMu.Lock() + defer renderMu.Unlock() + + want := recursivegrid.ComputeGridCells(picked, domain.GridDimensions{Cols: 2, Rows: 2}) + for idx, cell := range overlay.animRects { + if cell != want[idx] { + t.Fatalf("cell %d settled on %v, want %v", idx, cell, want[idx]) + } + } +} + +func TestWinOverlayDrawRecursiveGrid_ZeroDurationOrDisabledPaintsImmediately(t *testing.T) { + overlay, renderMu := newTestWinOverlay(t) + + screen := image.Rect(0, 0, 400, 400) + picked := image.Rect(200, 200, 400, 400) + + drawDepth(overlay, renderMu, screen, 1, true, 0) + + if done := drawDepth(overlay, renderMu, picked, 2, true, 0); done != nil { + t.Fatal("a zero duration must paint the new depth immediately") + } + + if done := drawDepth(overlay, renderMu, screen, 1, false, 80*time.Millisecond); done != nil { + t.Fatal("recursive_grid.animation.enabled = false must paint the new depth immediately") + } +} + +func TestWinOverlayDrawRecursiveGrid_ANewDrawCancelsTheRunningTransition(t *testing.T) { + overlay, renderMu := newTestWinOverlay(t) + + screen := image.Rect(0, 0, 400, 400) + picked := image.Rect(200, 200, 400, 400) + + const duration = 2 * time.Second + + drawDepth(overlay, renderMu, screen, 1, true, duration) + + done := drawDepth(overlay, renderMu, picked, 2, true, duration) + if done == nil { + t.Fatal("a depth change with the animation enabled must start a transition") + } + + // Another mode's draw takes the surface; the zoom must stop at once. + renderMu.Lock() + overlay.DrawHints(nil, hintscomponent.StyleMode{}, badge.HintOnTarget) + renderMu.Unlock() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("the transition kept running after another draw took the surface") + } +} diff --git a/internal/config/platform_support.go b/internal/config/platform_support.go index e0b9d387b..02a3610fd 100644 --- a/internal/config/platform_support.go +++ b/internal/config/platform_support.go @@ -20,9 +20,8 @@ const ( "score each word" noteVisionRectangles = "rectangle detection has no OCR answer, so it stays macOS-only " + "even where the vision strategy lands; that half is text-only" - noteRecursiveGridAnimation = "the Windows overlay backend has no grid transition animation" - noteSmoothCursor = "cursor movement is not animated on Windows" - noteSmoothScroll = "the Windows scroll is injected in one step; macOS and Linux animate it, " + + noteSmoothCursor = "cursor movement is not animated on Windows" + noteSmoothScroll = "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" noteKeyboardLayout = "the keyboard layout is detected rather than chosen outside macOS" noteMacOSSurfaces = "the menu bar, the Dock, Notification Center, Stage Manager, " + @@ -143,7 +142,7 @@ func PlatformSupport() parity.Declaration { "hints.vision.rectangle_max_aspect", ), - parity.On(parity.KindOption, darwinAndLinux, noteRecursiveGridAnimation, + parity.Everywhere(parity.KindOption, "recursive_grid.animation.enabled", "recursive_grid.animation.duration_ms", ), From 27648164ace60b238cc02300d1629ac73c0ea930 Mon Sep 17 00:00:00 2001 From: Kyle Wong <62775956+y3owk1n@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:42:52 +0800 Subject: [PATCH 3/3] improve(overlay): match the macOS grid transition feel on Linux and Windows The macOS zoom is a 120Hz timer and a full redraw like the software paths, so what set it apart was four choices, not the compositor: interpolated edges land on the nearest pixel rather than truncating towards the origin, a keystroke that interrupts a running zoom continues it on a linear curve instead of easing in again, the virtual pointer rides the zoom from where it was to where the new depth puts it, and the progress a frame paints is read once the frame holds the lock. All four now live beside the shared arithmetic and both backends use them. The plain-grid row leaves the transition table: no platform animates a subgrid opening, so the row described nothing. --- docs/CROSS_PLATFORM.md | 3 +- .../overlay/linux/overlay_shared_cgo.go | 52 ++++++++++------ .../adapter/overlay/render/motion/motion.go | 60 ++++++++++++++++--- .../overlay/render/motion/motion_test.go | 45 ++++++++++++++ internal/adapter/overlay/windows/features.go | 9 ++- internal/adapter/overlay/windows/overlay.go | 10 +++- .../adapter/overlay/windows/transition.go | 26 +++++--- 7 files changed, 169 insertions(+), 36 deletions(-) diff --git a/docs/CROSS_PLATFORM.md b/docs/CROSS_PLATFORM.md index 70c19ecc7..a4644e7e5 100644 --- a/docs/CROSS_PLATFORM.md +++ b/docs/CROSS_PLATFORM.md @@ -923,7 +923,7 @@ important thing to know before touching overlay code: | Animation | macOS | Linux X11 / Wayland | Windows | | ---------------------------- | ------------------------------------ | ---------------------------------- | ---------------------------------- | -| **Grid transition** | CoreAnimation, ease-in-out @120Hz | goroutine, smoothstep @120fps | goroutine, smoothstep @120fps, presented on the UI thread | +| **Grid transition** | NSTimer @120Hz, ease-in-out, full redraw | goroutine, ease-in-out @120fps | goroutine, ease-in-out @120fps, presented on the UI thread | | **Mouse action indicator** | `CABasicAnimation` (scale + opacity) | goroutine, scale + opacity @120fps | goroutine, cubic easing @60fps | | **Smooth cursor** | ✅ stepped linear interpolation | ✅ stepped linear interpolation | ❌ | | **Smooth scroll** | ✅ ease-out cubic | ❌ | ❌ | @@ -946,7 +946,6 @@ discovery rather than the mode itself. | **Hints** | Search input badge | ✅ | ✅ Cairo badge | ✅ | | **Hints** | Label arrow / tail | ✅ NSBezierPath | ✅ Cairo triangle | ✅ sampled triangle, see below | | **Hints** | Label placement | ✅ top / center / bottom | ✅ top / center / bottom | ✅ top / center / bottom | -| **Grid** | Transition animation | ✅ | ✅ | ✅ | | **Grid** | Virtual pointer indicator | ✅ | ✅ | ✅ | | **Grid** | What an open subgrid shows | ✅ the subgrid alone | ✅ the subgrid alone | ⚠️ the parent cells return under it on the next repaint | | **Recursive grid**| Transition animation | ✅ | ✅ | ✅ | diff --git a/internal/adapter/overlay/linux/overlay_shared_cgo.go b/internal/adapter/overlay/linux/overlay_shared_cgo.go index 034a1923b..51b277a1d 100644 --- a/internal/adapter/overlay/linux/overlay_shared_cgo.go +++ b/internal/adapter/overlay/linux/overlay_shared_cgo.go @@ -150,6 +150,14 @@ type sharedOverlay struct { lastDepth int lastRects []image.Rectangle currentAnimRects []image.Rectangle + // animSettled says the transition that painted currentAnimRects reached + // its last frame; one that has not is continued rather than restarted. + animSettled bool + // animPointer is where the last transition frame painted the virtual + // pointer, and lastPointer where the last settled frame did: the pointer + // rides the zoom from one of them to where the new frame puts it. + animPointer image.Point + lastPointer recursivegridcomponent.VirtualPointerState } // The exported methods below are what the manager calls on a backend. They @@ -565,7 +573,12 @@ func (o *sharedOverlay) drawRecursiveGridWithSubKeyPreview( duration = 50 * time.Millisecond } + continuing := len(o.currentAnimRects) > 0 && !o.animSettled fromRects := o.buildFromRects(cellRects, bounds) + fromPointer := motion.PointerOrigin( + virtualPointer.Position, o.lastPointer.Position, o.animPointer, + o.lastPointer.Visible, continuing, + ) keyRunes := []rune(strings.ToUpper(keys)) nextKeyRunes := []rune(strings.ToUpper(nextKeys)) @@ -578,7 +591,7 @@ func (o *sharedOverlay) drawRecursiveGridWithSubKeyPreview( fromRects, cellRects, keyRunes, nextKeyRunes, nextDims, - style, virtualPointer, + style, virtualPointer, fromPointer, continuing, duration, animStop, animDone, ) } else { @@ -592,6 +605,7 @@ func (o *sharedOverlay) drawRecursiveGridWithSubKeyPreview( o.hasLast = true o.lastBounds = bounds o.lastDepth = depth + o.lastPointer = virtualPointer o.lastRects = make([]image.Rectangle, len(cellRects)) copy(o.lastRects, cellRects) } @@ -1044,6 +1058,8 @@ func (o *sharedOverlay) startGridAnimation( nextDims domain.GridDimensions, style recursivegridcomponent.Style, virtualPointer recursivegridcomponent.VirtualPointerState, + fromPointer image.Point, + continuing bool, duration time.Duration, stopCh chan struct{}, doneCh chan struct{}, @@ -1051,19 +1067,26 @@ func (o *sharedOverlay) startGridAnimation( o.srf.syncBeforeAnimation() startTime := time.Now() + o.animSettled = false - renderFrame := func(rawProgress float64) { - if rawProgress >= 1.0 { - rawProgress = 1.0 - } + // Called under renderMu. The progress is read here rather than before the + // lock was taken, so a frame that waited on it paints where the cells are + // now rather than where they were when it was scheduled. + renderFrame := func() float64 { + rawProgress := min(float64(time.Since(startTime))/float64(duration), 1) + progress := motion.Eased(rawProgress, continuing) - interpCells := motion.LerpRects(fromRects, toRects, motion.EaseInOut(rawProgress)) + interpCells := motion.LerpRects(fromRects, toRects, progress) + pointer := virtualPointer + pointer.Position = motion.LerpPoint(fromPointer, virtualPointer.Position, progress) if !o.srf.beginFrame() { - return + return rawProgress } o.currentAnimRects = interpCells + o.animPointer = pointer.Position + o.animSettled = rawProgress >= 1 o.srf.clearFrame() o.drawFrame( @@ -1072,8 +1095,10 @@ func (o *sharedOverlay) startGridAnimation( nextKeyRunes, nextDims, style, - virtualPointer, + pointer, ) + + return rawProgress } go func() { @@ -1097,13 +1122,6 @@ func (o *sharedOverlay) startGridAnimation( default: } - elapsed := time.Since(startTime) - - rawProgress := float64(elapsed) / float64(duration) - if rawProgress >= 1.0 { - rawProgress = 1.0 - } - renderStart := time.Now() renderMu := o.renderMu @@ -1122,13 +1140,13 @@ func (o *sharedOverlay) startGridAnimation( } } - renderFrame(rawProgress) + rawProgress := renderFrame() if renderMu != nil { renderMu.Unlock() } - if rawProgress >= 1.0 { + if rawProgress >= 1 { return } diff --git a/internal/adapter/overlay/render/motion/motion.go b/internal/adapter/overlay/render/motion/motion.go index 9f69d5f44..2a3a13474 100644 --- a/internal/adapter/overlay/render/motion/motion.go +++ b/internal/adapter/overlay/render/motion/motion.go @@ -2,12 +2,15 @@ // transition: the easing curve, the interpolation between the cells of two // depths, and where a transition starts from. The Linux (Cairo) and Windows // (Direct2D / GDI) backends both drive a frame loop from here, so a depth -// change zooms the same way on each; macOS hands the same curve to -// CoreAnimation as kCAMediaTimingFunctionEaseInEaseOut. +// change zooms the same way on each. macOS evaluates the same curve, the +// kCAMediaTimingFunctionEaseInEaseOut control points, on its own 120Hz timer +// (platform/darwin/overlay_darwin.m), and the choices that make its zoom read +// as smooth are written down here beside the arithmetic they belong to. package motion import ( "image" + "math" "time" ) @@ -36,11 +39,54 @@ func EaseInOut(progress float64) float64 { return progress * progress * (smoothStep3 - smoothStep2*progress) } +// Eased maps a raw progress in [0,1] onto the transition curve. A transition +// that continues one still running keeps a linear curve: the cells are +// already moving, and easing in again from where they are reads as a +// stutter on every fast keystroke. macOS makes the same choice. +func Eased(rawProgress float64, continuing bool) float64 { + if continuing { + return min(max(rawProgress, 0), 1) + } + + return EaseInOut(rawProgress) +} + // Lerp linearly interpolates between a and b by t. func Lerp(a, b, t float64) float64 { return a + (b-a)*t } +// lerpPixel interpolates one coordinate and lands it on the nearest pixel. +// Truncating instead would pull every edge towards the origin by up to a +// pixel, unevenly between the two ends of a cell, which is the jitter that +// made the software transition read as rough. +func lerpPixel(from, to int, progress float64) int { + return int(math.Round(Lerp(float64(from), float64(to), progress))) +} + +// PointerOrigin answers where the virtual pointer starts its ride on a +// transition towards target: where an interrupted zoom last painted it, where +// the last settled frame drew it, or, with no pointer on screen before, at the +// target itself, so it appears in place rather than flying in. +func PointerOrigin( + target, settled, interrupted image.Point, + hadPointer, continuing bool, +) image.Point { + switch { + case !hadPointer: + return target + case continuing: + return interrupted + default: + return settled + } +} + +// LerpPoint interpolates a point by progress, to the nearest pixel. +func LerpPoint(from, to image.Point, progress float64) image.Point { + return image.Pt(lerpPixel(from.X, to.X, progress), lerpPixel(from.Y, to.Y, progress)) +} + // LerpRects interpolates each rectangle of from towards the one at the same // index of to by progress. The slices are expected to be the same length; // extra entries in from are ignored and missing ones are taken as already @@ -56,12 +102,10 @@ func LerpRects(from, to []image.Rectangle, progress float64) []image.Rectangle { } src := from[idx] - out[idx] = image.Rect( - int(Lerp(float64(src.Min.X), float64(dst.Min.X), progress)), - int(Lerp(float64(src.Min.Y), float64(dst.Min.Y), progress)), - int(Lerp(float64(src.Max.X), float64(dst.Max.X), progress)), - int(Lerp(float64(src.Max.Y), float64(dst.Max.Y), progress)), - ) + out[idx] = image.Rectangle{ + Min: LerpPoint(src.Min, dst.Min, progress), + Max: LerpPoint(src.Max, dst.Max, progress), + } } return out diff --git a/internal/adapter/overlay/render/motion/motion_test.go b/internal/adapter/overlay/render/motion/motion_test.go index c40d0b6fc..64257b124 100644 --- a/internal/adapter/overlay/render/motion/motion_test.go +++ b/internal/adapter/overlay/render/motion/motion_test.go @@ -58,6 +58,51 @@ func TestLerpRects_EndpointsAndMidpoint(t *testing.T) { } } +func TestEased_ContinuingTransitionStaysLinear(t *testing.T) { + t.Parallel() + + if got := motion.Eased(0.25, false); got != motion.EaseInOut(0.25) { + t.Fatalf("a fresh transition eases: got %v, want %v", got, motion.EaseInOut(0.25)) + } + + if got := motion.Eased(0.25, true); got != 0.25 { + t.Fatalf("a continued transition is linear: got %v, want 0.25", got) + } + + if got := motion.Eased(2, true); got != 1 { + t.Fatalf("a continued transition clamps: got %v, want 1", got) + } +} + +func TestPointerOrigin_NoPointerBeforeAppearsInPlace(t *testing.T) { + t.Parallel() + + target, settled, interrupted := image.Pt(1, 1), image.Pt(2, 2), image.Pt(3, 3) + + if got := motion.PointerOrigin(target, settled, interrupted, false, true); got != target { + t.Fatalf("no pointer before: got %v, want %v", got, target) + } + + if got := motion.PointerOrigin(target, settled, interrupted, true, true); got != interrupted { + t.Fatalf("continuing: got %v, want %v", got, interrupted) + } + + if got := motion.PointerOrigin(target, settled, interrupted, true, false); got != settled { + t.Fatalf("settled: got %v, want %v", got, settled) + } +} + +func TestLerpPoint_LandsOnTheNearestPixel(t *testing.T) { + t.Parallel() + + // 0 -> 3 at a third is 1.0 exactly for X; 0 -> 5 at a third is 1.67, + // which truncation would have put on 1 and rounding puts on 2. + got := motion.LerpPoint(image.Pt(0, 0), image.Pt(3, 5), 1.0/3.0) + if want := image.Pt(1, 2); got != want { + t.Fatalf("LerpPoint = %v, want %v", got, want) + } +} + func TestLerpRects_MissingOriginIsAlreadyArrived(t *testing.T) { t.Parallel() diff --git a/internal/adapter/overlay/windows/features.go b/internal/adapter/overlay/windows/features.go index ba3a71d10..7049fe68e 100644 --- a/internal/adapter/overlay/windows/features.go +++ b/internal/adapter/overlay/windows/features.go @@ -269,6 +269,7 @@ func (o *winOverlay) DrawRecursiveGrid( depth != o.lastDepth && !o.lastBounds.Empty() if shouldAnimate { + continuing := len(o.animRects) > 0 && !o.animSettled o.startTransition(transitionPlan{ fromRects: motion.TransitionOrigins( cellRects, bounds, o.animRects, o.lastRects, o.lastBounds, @@ -279,7 +280,12 @@ func (o *winOverlay) DrawRecursiveGrid( nextDims: nextDims, style: style, pointer: virtualPointer, - duration: animDuration, + fromPointer: motion.PointerOrigin( + virtualPointer.Position, o.lastPointer.Position, o.animPointer, + o.lastPointer.Visible, continuing, + ), + continuing: continuing, + duration: animDuration, }) } else { o.animRects = nil @@ -290,6 +296,7 @@ func (o *winOverlay) DrawRecursiveGrid( o.lastDepth = depth o.lastBounds = bounds o.lastRects = cellRects + o.lastPointer = virtualPointer } // paintRecursiveGrid paints one whole recursive-grid frame, the cells at the diff --git a/internal/adapter/overlay/windows/overlay.go b/internal/adapter/overlay/windows/overlay.go index 0f8704eec..6ba508bed 100644 --- a/internal/adapter/overlay/windows/overlay.go +++ b/internal/adapter/overlay/windows/overlay.go @@ -64,7 +64,15 @@ type winOverlay struct { lastRects []image.Rectangle // animRects are the cells the last transition frame painted, so a depth // change arriving mid-zoom continues from the screen rather than jumping. - animRects []image.Rectangle + animRects []image.Rectangle + // animSettled says the transition that painted animRects reached its + // last frame; one that has not is continued rather than restarted. + animSettled bool + // animPointer is where the last transition frame painted the virtual + // pointer, and lastPointer where the last settled frame did: the pointer + // rides the zoom from one of them to where the new frame puts it. + animPointer image.Point + lastPointer recursivegridcomponent.VirtualPointerState transitionCancel context.CancelFunc transitionDone chan struct{} } diff --git a/internal/adapter/overlay/windows/transition.go b/internal/adapter/overlay/windows/transition.go index ae0577c19..9a33fbd6a 100644 --- a/internal/adapter/overlay/windows/transition.go +++ b/internal/adapter/overlay/windows/transition.go @@ -32,7 +32,11 @@ type transitionPlan struct { nextDims domain.GridDimensions style recursivegridcomponent.Style pointer recursivegridcomponent.VirtualPointerState - duration time.Duration + fromPointer image.Point + // continuing says the transition picks up one still running, which + // keeps the curve linear (motion.Eased). + continuing bool + duration time.Duration } // startTransition begins painting plan over its duration. The caller holds @@ -43,6 +47,8 @@ func (o *winOverlay) startTransition(plan transitionPlan) { o.transitionCancel = cancel o.transitionDone = done + o.animSettled = false + go o.runTransition(ctx, plan, done) } @@ -52,12 +58,14 @@ func (o *winOverlay) runTransition(ctx context.Context, plan transitionPlan, don startTime := time.Now() for { - rawProgress := min(float64(time.Since(startTime))/float64(plan.duration), 1) - frameStart := time.Now() o.renderMu.Lock() + // Read after the lock, so a frame that waited on it paints where the + // cells are now rather than where they were when it was scheduled. + rawProgress := min(float64(time.Since(startTime))/float64(plan.duration), 1) + // The draw that canceled this transition may have been waiting for // the lock this frame now holds; its cancel is the last word. select { @@ -69,16 +77,20 @@ func (o *winOverlay) runTransition(ctx context.Context, plan transitionPlan, don } if o.window != nil && o.window.Healthy() { - o.animRects = motion.LerpRects( - plan.fromRects, plan.toRects, motion.EaseInOut(rawProgress), - ) + progress := motion.Eased(rawProgress, plan.continuing) + pointer := plan.pointer + pointer.Position = motion.LerpPoint(plan.fromPointer, plan.pointer.Position, progress) + + o.animRects = motion.LerpRects(plan.fromRects, plan.toRects, progress) + o.animPointer = pointer.Position + o.animSettled = rawProgress >= 1 o.paintRecursiveGrid( o.animRects, plan.keyRunes, plan.nextKeyRunes, plan.nextDims, plan.style, - plan.pointer, + pointer, ) }