Skip to content

fix(menubar): draw the pulse on CALayer to end 60% idle CPU - #5

Merged
Gnonymous merged 2 commits into
mainfrom
fix/menubar-animation-cpu
Aug 1, 2026
Merged

fix(menubar): draw the pulse on CALayer to end 60% idle CPU#5
Gnonymous merged 2 commits into
mainfrom
fix/menubar-animation-cpu

Conversation

@Gnonymous

Copy link
Copy Markdown
Owner

What

The menu-bar glyph animated through SwiftUI. A running SwiftUI animation inside an NSStatusItem re-evaluates the body every frame, and each of those frames dragged the whole item through Auto Layout and -[NSStatusItem _updateReplicants] (which mirrors the item onto every screen and the Control Center), re-resolving both Text runs on the way.

Sampled at 3086 / 5623 main-thread samples — a permanent 60% of a core with nothing on screen changing size.

It was not the curve

Two fixes that looked obvious did nothing, and ruling them out is what located the real cost:

Attempt Result
sin(phase) opacity → two constant endpoints 65%
Gate NSHostingView.invalidateIntrinsicContentSize 62%
Disable the animation outright 0%

The chain is driven by redraw, not by size invalidation, so nothing on the SwiftUI side reaches it. The cost is the hosting, not the animation curve.

Fix

PulseLayerView (new, AppKit) strokes PulseGlyph into a CAShapeLayer and breathes via CABasicAnimation — Core Animation interpolates on the render server, so the app submits once and burns nothing per frame. The readout stays SwiftUI as a sibling view, so only a text change can resize the item, and that happens on the 30s refresh. PulseIcon keeps the identical mark as a still frame for the offscreen renderer.

Measured

Release build, same machine and workload:

CPU median _updateReplicants samples
before 60%, sustained 3086
after 0.0% 0

The 30s aggregation is now the only CPU event, showing as a brief spike between long stretches of silence.

Two real bugs fixed along the way

  • Every active flip stacked another repeatForever without cancelling the last, so idle cost grew with uptime — 198% after three weeks.
  • Tempo never followed throughput: period read throughput but the animation only restarted when active flipped. Now bucketed, so a busy agent visibly pulses faster without restarting on every refresh.

Verification

  • swift build clean
  • --self-test 24/24 pass
  • --render-menubar output pixel-identical to before
  • Live status item confirmed by hand: animation smooth, layout and alignment unchanged

🤖 Generated with Claude Code

Gnonymous and others added 2 commits August 2, 2026 01:15
The menu-bar glyph animated through SwiftUI, and a running SwiftUI animation
inside an NSStatusItem re-evaluates the body every frame. Each of those frames
dragged the whole item through Auto Layout and -[NSStatusItem _updateReplicants]
(which mirrors the item onto every screen and the Control Center), re-resolving
both Text runs on the way. Sampled at 3086/5623 main-thread samples — a
permanent 60% of a core with nothing on screen changing size.

It was not the sin() curve. Rewriting the opacity as two constant endpoints left
it at 65%, and gating NSHostingView.invalidateIntrinsicContentSize left it at
62%; the chain is driven by redraw, not by size invalidation. Disabling the
animation outright dropped it to 0%, which is what identified the hosting rather
than the curve as the cost.

So the glyph moves to AppKit: PulseLayerView strokes PulseGlyph into a
CAShapeLayer and breathes via CABasicAnimation, which Core Animation interpolates
on the render server — the app submits once and burns nothing per frame. The
readout stays SwiftUI as a sibling view, so only a text change can resize the
item, and that happens on the 30s refresh. PulseIcon keeps the same mark as a
still frame for the offscreen renderer.

Measured, release build, same machine and workload:
  before  60% median, sustained
  after    0% median, with the 30s aggregation showing as a brief spike

Two real bugs fell out of the investigation and are fixed by the rewrite:
every active-flip used to stack another repeatForever without cancelling the
last (idle cost grew with uptime — 198% after three weeks), and the tempo never
followed throughput because the animation only restarted when `active` flipped.
Tempo is now bucketed so a busy agent visibly pulses faster without restarting
the animation on every refresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Gnonymous
Gnonymous merged commit d1629e5 into main Aug 1, 2026
1 check passed
@Gnonymous
Gnonymous deleted the fix/menubar-animation-cpu branch August 1, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant