Skip to content

Menu bar: stop animating the countdown digits - #111

Merged
krishhgg merged 2 commits into
krishhgg:mainfrom
armagg:fix/countdown-cpu
Oct 17, 2026
Merged

krishhgg merged 2 commits into
krishhgg:mainfrom
armagg:fix/countdown-cpu

Conversation

@armagg

@armagg armagg commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What changes

A session held the app at about 46% of one core for as long as it ran, with
the lid open.

The countdown text carried .contentTransition(.numericText(countsDown: true))
on a 0.18 s spring. An animated text change is a content change on every
frame, and AppKit re-renders the whole status item on each one:
NSStatusItem _updateReplicants takes a fresh snapshot of the SwiftUI host,
lays it out again and blurs it (RenderBox, vImageSepConvolve). sample
puts one content change at about 23 ms of main thread, so the spring was
running about twenty of them a second, for the whole session.

The digits now change in one step, as the menu bar clock's do. Same session,
same Mac: about 46% before, about 2% after. They still change every second,
they just don't roll.

Motion.tick had no other caller and is gone, along with its reduceMotion
accessor.

How it was tested

  • swift test: 1235 tests, 0 failures.
  • swift build -c release -Xswiftc -warnings-as-errors: clean.
  • New UIStatusTests.testTheCountdownDoesNotAnimateItsDigits pins
    Motion.countdownTick == nil, so a re-added tick fails a test. The old
    testTickAnimationIsShorterThanBaseAndHoldIsSubSecond asserted the
    spring's shape, so it becomes testHoldToEndIsSubSecond and keeps the
    hold-duration half.
  • testCountdownTextTicksInHoursShapeAndPausesWithLid still passes. The
    1 Hz redraw and the lid pause/resume are untouched.

I also measured the alternatives, so nobody has to ask: drawingGroup()
52%, NSHostingView.sizingOptions = [] 51%, contentTransition(.opacity)
with the animation 8%, redraw slowed to 4 s 2%. The cost is AppKit's
snapshot of the item, not anything in the view, and redrawing less often
than the countdown needs buys nothing.

Real-Mac evidence

macOS 27.0.1 (26A434)   Mac15,6   Apple M3 Pro
Screenshot 2026-10-13 at 11 58 42

CPU screenshots attached: ~46% with the countdown animating, ~2% without,
same 40 minute session, same machine.

I built with ./scripts/build-app.sh --output /tmp/insomnia-lab and ran the
bundle from /tmp rather than running install.sh, which would have
replaced the v0.1.0 install in my ~/Applications. Same bundle either way,
different path.

Not run: a real or simulated lid close. It would SIGSTOP the Telegram and
WhatsApp I have open and pause Docker. The countdown's lid pause is
SessionManager.pauseCountdown, which this does not touch, and the test
above covers it.

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no blocking issue remains.

Summary

The countdown now changes its digits without animation, avoiding repeated menu bar redraws.

  • Menu bar countdown digits now change in one step.

Reviews (2) · Last reviewed commit: "Menu bar: test that the rendered countdo..." · Reviewed by Greptile

A session held the app at about 46% of one core for as long as it ran.
`sample` of the shipped build shows where the time goes: every content
change in the status item makes AppKit take a fresh snapshot of the SwiftUI
host (`NSStatusItem _updateReplicants`), which lays it out again and blurs
it (`RenderBox`, `vImageSepConvolve`), about 23 ms of main thread each. The
countdown's `contentTransition(.numericText)` on the 0.18 s `Motion.tick`
spring is a content change on every frame, so it ran about twenty of them a
second.

The digits now change in one step, as the menu bar clock's do: no content
transition and no animation (`Motion.countdownTick` is nil). Measured on an
M3 Pro, macOS 27.0.1, with a 40 minute session and the mean of 40 samples of
`ps -o %cpu=` taken 0.5 s apart: 46.1% before, 2.3% after. One render a
second is affordable; twenty are not.

Alternatives measured and rejected: freezing the text while keeping every
other modifier reads 0.2%, so the digit roll is the whole cost; a
fixed-duration ease instead of the spring still reads about 50%; slowing the
redraw to 4 s reads 2.0%, no better than 2.3%, so there is nothing to win by
redrawing less often than the countdown needs. `drawingGroup()` and an empty
`NSHostingView.sizingOptions` changed nothing (52.2% and 50.8%).

The countdown still changes every second; only its transition is gone.
Comment thread Sources/Insomnia/UI/StatusRootView.swift
Greptile's review of krishhgg#111 was right: `testTheCountdownDoesNotAnimateItsDigits`
asserts `Motion.countdownTick` is nil, which is the constant the view is
supposed to read, not the view. Putting
`.contentTransition(.numericText(countsDown: true))` back in `StatusRootView`
with any animation at all restores the per-frame status item re-render the
change removed, and that test would stay green.

The new test renders the view the controller installs. It lays the host out
at its fitting size, rasterises it offscreen (`bitmapImageRepForCachingDisplay`
and `cacheDisplay`, no window and no run loop), then advances the session by
a second on the same host and rasterises the very next frame. With no
transition that frame already reads the new digits, so it is byte-identical
to a countdown rendered fresh with the new value. Under the removed
`.numericText` transition the frame is still rolling from the old digits and
does not match, which fails the test.

Verified against the regression: with the transition and a 0.18 s spring put
back in the view, the changed frame is 5941 bytes against the fresh 6120 and
the test fails with "the changed frame must already read the new digits, not
roll to them". The raster is deterministic: three consecutive runs of the
suite produce the same 5934/6120/6120.

`swift test`: 1236 tests, 0 failures. `swift build -c release -Xswiftc
-warnings-as-errors`: clean.
@armagg

armagg commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Real-Mac report for 9325f63

  • Build. Clean checkout of the head, with no other pull requests applied.

    • git rev-parse HEAD → 9325f633164c021e0658be0c0b13c53c8bb67ace
    • git status --short → nothing
  • Mac. macOS 27.0.1 (26A434), Mac15,6, Apple M3 Pro

  • Checks. The delta from 1cd9d3a, the head the report above covers, is one file:

    $ git diff --name-only 1cd9d3a 9325f63
    Tests/InsomniaTests/UIStatusTests.swift
    

    Nothing under Sources/ changed, so the app is built from the same sources and the "Menu bar" item this pull request adds to docs/releasing.md reads what it read before: a mean of 40 ps -o %cpu= samples 0.5 s apart of 46.1% with the digits rolling and 2.3% with them landing in one step, over the same 40 minute session on the same Mac (screenshots are in the body). The new work is a test in the test target, which is not in the app bundle, so it cannot move that reading.

    swift test on this head: 1236 tests, 0 failures. swift build -c release -Xswiftc -warnings-as-errors: clean. Both also pass on the macOS 26 and macOS 27 CI jobs for this head.

  • Not run. Nothing new. A test-only delta adds no real-Mac check. The lid-close item stays unrun for the same reason as before, that it would SIGSTOP the Telegram and WhatsApp I have open and pause Docker, and this change does not touch SessionManager.pauseCountdown.

I am happy to re-run the CPU sample live on this head if you would rather see it from the head's own build; it is the same sources, so I expected no difference and did not repeat a 40 minute session for it.

SergeiNikolenko added a commit to SergeiNikolenko/Insomnia that referenced this pull request Oct 16, 2026
@armagg

armagg commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Just a reminder :)

@krishhgg

krishhgg commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

End-to-end test in two VMs

This PR works in both VMs, macOS 26.6.2 and 27.0. The countdown now changes once a second with no frames in between, and the app's CPU use during a session drops below half a percent. I also found an older bug: now and then the countdown skips a second and then shows the next value for about 2 s. Main does it too, and this PR doesn't touch the code behind it.

Setup

Two Tart VMs on the maintainer's Mac mini, main (b6a7499) against this PR's head (9325f63):

  • macOS 27.0 (26A428): each version built and installed from source with scripts/install.sh (Xcode 27).
  • macOS 26.6.2 (25G83): each version installed with scripts/install.sh --app, from a bundle built from the same commit in the macOS 27 VM. This VM has only the Command Line Tools, which can't build the app.

A script started each session from the menu bar with a mouse click and Enter, waited for the "session started until" log line, then took screenshots of a 300x30 area around the countdown about 115 times a second (ScreenCaptureKit).

Results

Check macOS 26.6.2, main macOS 26.6.2, PR macOS 27.0, main macOS 27.0, PR
App CPU during a session (ps -o %cpu, 60 samples over 30 s) 4.4% mean, 7.0% max 0.3% mean, 1.7% max 13.5% mean, 19.2% max 0.4% mean, 1.4% max
Different images of the countdown area in 8 s 396 9 394 9
Sessions with a skipped second, out of 8 1 (two skips) 1 0 1

"Different images" counts every time any pixel in the area changed. With this PR that's once per second, 8 changes plus the first image. On main the digit roll keeps redrawing for most of each second, about 50 times a second.

What it looks like

Each GIF is 8 s of one session at 3x, with the real time between frames (GIFs top out at 50 frames a second).

macOS 27.0, main:

macOS 27 main

macOS 27.0, this PR:

macOS 27 PR

macOS 26.6.2, main:

macOS 26 main

macOS 26.6.2, this PR:

macOS 26 PR

The skipped second (older than this PR)

With this PR, each tile is one value the countdown showed, with how long it stayed. Red marks a value that stayed about 2 s. The second before it never appeared.

PR holds

Main does the same. Here main is sampled every 0.2 s on macOS 26.6.2: 3:59:52 stays until about 2.8 s and rolls straight to 3:59:50, which stays until about 4.8 s and rolls straight to 3:59:48. 3:59:51 and 3:59:49 never show.

main hold

The likely cause, from reading the code but not proven: armCountdownTimer fires on whole wall-clock seconds with a 0.1 s tolerance, and formatCountdown rounds the remaining time up. If the session's end time falls within about 0.1 s after a whole second, a tick that fires a little late rounds down one second early, and the next on-time tick shows the same value again. This PR changes only the animation, not the timer or the rounding. Without the roll the jump is easier to see.

Rerun on macOS 27.0.1

The macOS 27 VM was updated to 27.0.1 (26A434), the newest release, and these runs were repeated with the same commits and scripts. The results match 27.0. The macOS 26 VM stays on 26.6.2, the version GitHub's hosted macOS 26 runner uses.

Check macOS 27.0.1, main macOS 27.0.1, PR
App CPU during a session (60 samples over 30 s) 13.9% mean, 18.3% max 0.4% mean, 1.5% max
Different images of the countdown area in 8 s 393 9
Sessions with a skipped second, out of 8 1 0

macOS 27.0.1, main:

macOS 27.0.1 main

macOS 27.0.1, this PR:

macOS 27.0.1 PR

The sessions are in holds.txt and the run log is run-lab27.log.

Not covered

  • Real hardware. The VMs use virtual graphics, so the CPU numbers differ from a real Mac. @armagg's report covers a real Mac on macOS 27.0.1.
  • ProMotion (120 Hz) displays and the Reduce Motion setting.

Everything is in pr-111: the GIFs, the full image sheets, and holds.txt with all 32 sessions. Run logs are in logs and the scripts in scripts.

🤖 Generated with Claude Code

@krishhgg krishhgg added the accepted The owner accepted this pull request; merge-gate/accepted reads it. Only the owner adds it. label Oct 17, 2026
@krishhgg
krishhgg merged commit 283bb15 into krishhgg:main Oct 17, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

accepted The owner accepted this pull request; merge-gate/accepted reads it. Only the owner adds it.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants