diff --git a/docs/CROSS_PLATFORM.md b/docs/CROSS_PLATFORM.md index 7fbb61e11..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 | ❌ | +| **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,10 +946,9 @@ 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 | ✅ | ✅ | ❌ | +| **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 +1077,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 +1183,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/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..51b277a1d 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" @@ -149,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 @@ -564,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)) @@ -577,7 +591,7 @@ func (o *sharedOverlay) drawRecursiveGridWithSubKeyPreview( fromRects, cellRects, keyRunes, nextKeyRunes, nextDims, - style, virtualPointer, + style, virtualPointer, fromPointer, continuing, duration, animStop, animDone, ) } else { @@ -591,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) } @@ -899,8 +914,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 +1042,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( @@ -1086,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{}, @@ -1093,31 +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) - 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, 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( @@ -1126,8 +1095,10 @@ func (o *sharedOverlay) startGridAnimation( nextKeyRunes, nextDims, style, - virtualPointer, + pointer, ) + + return rawProgress } go func() { @@ -1151,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 @@ -1176,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 new file mode 100644 index 000000000..2a3a13474 --- /dev/null +++ b/internal/adapter/overlay/render/motion/motion.go @@ -0,0 +1,171 @@ +// 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 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" +) + +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) +} + +// 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 +// 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.Rectangle{ + Min: LerpPoint(src.Min, dst.Min, progress), + Max: LerpPoint(src.Max, dst.Max, 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..64257b124 --- /dev/null +++ b/internal/adapter/overlay/render/motion/motion_test.go @@ -0,0 +1,159 @@ +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 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() + + 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/internal/adapter/overlay/windows/features.go b/internal/adapter/overlay/windows/features.go index 412e9a47e..7049fe68e 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,58 @@ 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 { + continuing := len(o.animRects) > 0 && !o.animSettled + 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, + fromPointer: motion.PointerOrigin( + virtualPointer.Position, o.lastPointer.Position, o.animPointer, + o.lastPointer.Visible, continuing, + ), + continuing: continuing, + 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 + o.lastPointer = virtualPointer +} + +// 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..6ba508bed 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,30 @@ 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 + // 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{} } -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 +119,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 +180,7 @@ func (o *winOverlay) Hide() { return } + o.cancelTransition() o.suppressDraw = true o.currentSubgrid = nil o.gridPointer = recursivegridcomponent.VirtualPointerState{} @@ -178,6 +205,7 @@ func (o *winOverlay) ClearCache() { return } + o.forgetTransition() o.cachedGrid = nil o.cachedStyle = gridcomponent.Style{} o.currentPrefix = "" @@ -193,13 +221,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 +340,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..9a33fbd6a --- /dev/null +++ b/internal/adapter/overlay/windows/transition.go @@ -0,0 +1,142 @@ +//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 + 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 +// 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 + + o.animSettled = false + + 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 { + 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 { + case <-ctx.Done(): + o.renderMu.Unlock() + + return + default: + } + + if o.window != nil && o.window.Healthy() { + 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, + 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", ), 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 \