Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions docs/CROSS_PLATFORM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ❌ | ❌ |
Expand All @@ -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) | ❌ |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/overlay/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
28 changes: 3 additions & 25 deletions internal/adapter/overlay/linux/animation_cgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"image"
"math"
"time"

"github.com/y3owk1n/neru/internal/adapter/overlay/render/motion"
)

const (
Expand Down Expand Up @@ -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
Expand All @@ -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.
}
Expand Down
120 changes: 42 additions & 78 deletions internal/adapter/overlay/linux/overlay_shared_cgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand All @@ -577,7 +591,7 @@ func (o *sharedOverlay) drawRecursiveGridWithSubKeyPreview(
fromRects, cellRects,
keyRunes, nextKeyRunes,
nextDims,
style, virtualPointer,
style, virtualPointer, fromPointer, continuing,
duration, animStop, animDone,
)
} else {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -1086,38 +1058,35 @@ 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{},
) {
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(
Expand All @@ -1126,8 +1095,10 @@ func (o *sharedOverlay) startGridAnimation(
nextKeyRunes,
nextDims,
style,
virtualPointer,
pointer,
)

return rawProgress
}

go func() {
Expand All @@ -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
Expand All @@ -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
}

Expand Down
Loading
Loading