fix(menubar): draw the pulse on CALayer to end 60% idle CPU - #5
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The menu-bar glyph animated through SwiftUI. A running SwiftUI animation inside an
NSStatusItemre-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 bothTextruns 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:
sin(phase)opacity → two constant endpointsNSHostingView.invalidateIntrinsicContentSizeThe 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) strokesPulseGlyphinto aCAShapeLayerand breathes viaCABasicAnimation— 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.PulseIconkeeps the identical mark as a still frame for the offscreen renderer.Measured
Release build, same machine and workload:
_updateReplicantssamplesThe 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
activeflip stacked anotherrepeatForeverwithout cancelling the last, so idle cost grew with uptime — 198% after three weeks.periodreadthroughputbut the animation only restarted whenactiveflipped. Now bucketed, so a busy agent visibly pulses faster without restarting on every refresh.Verification
swift buildclean--self-test24/24 pass--render-menubaroutput pixel-identical to before🤖 Generated with Claude Code