Skip to content

Turn the gauge hands with Core Animation, not SwiftUI (#69) - #70

Merged
evandhoffman merged 1 commit into
mainfrom
fix/69-chart-cpu
Sep 26, 2026
Merged

evandhoffman merged 1 commit into
mainfrom
fix/69-chart-cpu

Conversation

@evandhoffman

Copy link
Copy Markdown
Contributor

Fixes #69.

The cause

The needle's travel time is the sampling interval, so it is always mid-sweep. As an animatable SwiftUI Shape, that kept the whole panel re-evaluating on the main thread at the display's refresh rate. A 10-second sample of a fresh 1.9.0 showed the main thread never idle, running display-cycle flushes and window layout back to back.

The change

  • DialHand: the needle and peak mark are CAShapeLayers rotated by a CABasicAnimation. The render server runs the sweep, so the main thread wakes once per sample.
  • An interrupted sweep continues from the angle the hand has reached. That angle is tracked in the view, because reading it back from the layer folds a 240° dial into ±180°.
  • The hand is not hit-testable, so a tile's drag and right-click still land on the tile.
  • Copy Image: ImageRenderer cannot draw a layer, so a copied dial uses the still NeedleShape / PeakMarkShape (liveHands: false).
  • AGENTS.md: one Key Concepts bullet with the rule and the reason.

Measurements

Your saved layout (10 gauges, 13 charts, 1 s sampling), release builds via Scripts/make-app.sh. Each figure is the mean of six 5-second top readings after a 20-second warm-up.

build CPU
main (c887960) 41.6%
hands not animated at all 8.6%
chart plot-width GeometryReader removed 40.6% (not the cause)
this change 6.8%

Tests

312 pass, and swiftformat --lint is clean.

  • The mechanism. The sweep is a layer animation. An interrupted sweep starts where the hand is. The same reading twice starts nothing. Off screen, the hand snaps to its reading. A click passes through the hand.
  • Copy Image. A copied dial has needle-coloured pixels. The live hands have none under ImageRenderer, which is why the still shapes exist.
  • Geometry. The live needle and the still one land within 3 px of each other at readings 0, 0.25, 0.5, 0.9 and 1.0.
  • The regression guard. The test fails on any SwiftUI .animation( or withAnimation in MonitorUI outside an allowlist (ReorderDrag's 0.12 s drop indicator). I checked it by putting the old animation back: it failed.

Not verified

  • Nobody has looked at it on screen. The geometry test compares offscreen renders instead. Please check the dials by eye before merging (Scripts/make-app.sh && open .build/monitor.app).
  • Mach ports. They sit at 330–340 in every build, including this one, over a 1.5-minute run. 1.9.0 held 450 after 6 hours, so a slow leak isn't ruled out. This change doesn't touch it.

The needle's travel time is the sampling interval, so it is always
mid-sweep. As an animatable SwiftUI Shape that kept the whole panel
re-evaluating on the main thread at the display's refresh rate, forever:
41.6% of a core at rest with ten gauges and thirteen charts.

DialHand is a layer rotated by a CABasicAnimation, which the render server
runs, so the main thread wakes once per sample and the needle still sweeps.
An interrupted sweep continues from the angle the hand has reached, tracked
rather than read back from the layer, since a 240-degree dial folds into
plus or minus 180 degrees there. The hand is not hit-testable, so a tile's
drag still starts on it.

ImageRenderer cannot draw a layer, so Copy Image draws a dial with the still
NeedleShape and PeakMarkShape instead.

Measured with the saved layout, mean of six 5-second top readings after a
20-second warm-up:

  main (c887960)                          41.6%
  hands not animated at all                8.6%
  chart plot-width reader removed         40.6%  (not the cause)
  this change                              6.8%

Tests: DialHandTests pins the layer animation, the interrupted sweep, and the
click pass-through; checks that a copied dial keeps its needle; compares where
the live and still needles land at five readings; and fails on any SwiftUI
.animation or withAnimation in MonitorUI outside an allowlist. That guard was
checked by putting the old animation back: it failed.
@evandhoffman

Copy link
Copy Markdown
Contributor Author

Checked on screen by the operator, 2026-09-25: the needles sweep smoothly and look the same as before.

--opus

@evandhoffman
evandhoffman merged commit 1b08324 into main Sep 26, 2026
2 checks passed
@evandhoffman
evandhoffman deleted the fix/69-chart-cpu branch September 26, 2026 01:34
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.

monitor.app 1.9.0 holds 65–88% of a CPU core at steady state, mostly SwiftUI Charts redraws

1 participant