diff --git a/Sources/Insomnia/UI/Motion.swift b/Sources/Insomnia/UI/Motion.swift index 009c4dd5..79f0e1ba 100644 --- a/Sources/Insomnia/UI/Motion.swift +++ b/Sources/Insomnia/UI/Motion.swift @@ -16,8 +16,18 @@ enum Motion { static let base: Animation = .spring(response: baseResponse, dampingFraction: baseDampingFraction) /// Focus bounce and icon taps: snappier, with a slight overshoot. static let snappy: Animation = .spring(response: 0.25, dampingFraction: 0.6) - /// One digit roll of the 1 Hz countdown; must settle well inside a second. - static let tick: Animation = .spring(response: 0.18, dampingFraction: 0.85) + /// The countdown's 1 Hz text change carries no animation. + /// + /// Every frame of an animation is a content change, 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`). One content change + /// measured at about 23 ms of main thread, so the 0.18 s spring this + /// replaced ran about twenty of them a second and held the app at about + /// 46% of one core for as long as a session lasted. With no animation a + /// session reads 2.3%, and one text change a second is affordable. The + /// digits change in one step, as the menu bar clock's do. + static let countdownTick: Animation? = nil /// How long the end button must be held before the session ends. static let holdDuration: TimeInterval = 0.6 /// Delay between consecutive pills appearing (reversed on collapse). @@ -116,10 +126,6 @@ enum Motion { reduceMotion ? reduced : snappy } - static func tick(reduceMotion: Bool = Motion.reduceMotion) -> Animation { - reduceMotion ? reduced : tick - } - /// Delay for pill `index` of `count` when expanding (`reversed == false`) /// or collapsing (`reversed == true`). Zero under Reduce Motion. static func staggerDelay(index: Int, count: Int, reversed: Bool, reduceMotion: Bool = Motion.reduceMotion) -> TimeInterval { diff --git a/Sources/Insomnia/UI/StatusRootView.swift b/Sources/Insomnia/UI/StatusRootView.swift index 9552ba6b..a4bbda16 100644 --- a/Sources/Insomnia/UI/StatusRootView.swift +++ b/Sources/Insomnia/UI/StatusRootView.swift @@ -187,8 +187,10 @@ struct StatusRootView: View { Text(countdownText) .font(.system(size: 13, weight: .medium, design: .rounded)) .monospacedDigit() - .contentTransition(.numericText(countsDown: true)) - .animation(Motion.tick(reduceMotion: reduceMotion), value: countdownText) + // No content transition and no animation: a per-frame one is a + // per-frame status item re-render, about 23 ms each. See + // `Motion.countdownTick`. + .animation(Motion.countdownTick, value: countdownText) .padding(.trailing, 1) .transition(Motion.countdownTransition(reduceMotion: reduceMotion)) .contentShape(Rectangle()) diff --git a/Tests/InsomniaTests/UIStatusTests.swift b/Tests/InsomniaTests/UIStatusTests.swift index 7206ada9..c7e23975 100644 --- a/Tests/InsomniaTests/UIStatusTests.swift +++ b/Tests/InsomniaTests/UIStatusTests.swift @@ -94,11 +94,84 @@ final class UIStatusTests: XCTestCase { } @MainActor - func testTickAnimationIsShorterThanBaseAndHoldIsSubSecond() { + func testHoldToEndIsSubSecond() { XCTAssertEqual(Motion.holdDuration, 0.6, accuracy: 0.0001) XCTAssertLessThan(Motion.holdDuration, 1) - XCTAssertEqual(Motion.tick(reduceMotion: true), Motion.reduced) - XCTAssertNotEqual(Motion.tick(reduceMotion: false), Motion.base) + } + + /// Every frame of an animated countdown is a content change, and AppKit + /// re-renders the whole status item on each one, about 23 ms of main + /// thread per frame. The 0.18 s spring that used to run here held the app + /// at about 46% of one core for the whole session, against 2.3% with no + /// animation. The tick must stay unanimated. + /// + /// This pins the constant only; the test below pins the view that has to + /// use it, since a transition re-added in the body animates the digits + /// with this still nil. + @MainActor + func testTheCountdownDoesNotAnimateItsDigits() { + XCTAssertNil(Motion.countdownTick) + } + + /// The digits must land in one step: the frame the text changes on + /// already reads the new value, exactly as a countdown rendered fresh + /// with that value would. The animated content transition this PR removed + /// (`.numericText(countsDown:)` under a 0.18 s spring) instead spends its + /// first frames rolling from the old digits, so the changed frame does + /// not match a fresh one. That is the regression: the cost is in AppKit + /// re-snapshotting the status item once per animated frame, and pinning + /// `Motion.countdownTick` to nil does not see a transition put back in + /// the view. + @MainActor + func testTheRenderedCountdownChangesItsDigitsInOneStep() async { + let h = Harness() + defer { h.home.destroy() } + let manager = h.makeManager() + await manager.start(duration: 4 * 3600) + XCTAssertEqual(manager.countdownText, "4:00:00") + let model = MenuBarModel() + model.phase = .running + + // The same view the controller installs, laid out at its own size and + // rasterised offscreen; no window and no run loop needed. + func makeHost() -> StatusHostingView { + let host = StatusItemController.makeHostingView(StatusRootView( + model: model, + manager: manager, + onTapIcon: {}, + onTapPill: { _ in }, + onTapCountdown: {}, + onHoldEnd: {}, + onWidthChange: { _ in } + )) + host.frame = NSRect(origin: .zero, size: host.fittingSize) + return host + } + func snapshot(_ host: StatusHostingView) -> Data { + host.layoutSubtreeIfNeeded() + host.displayIfNeeded() + guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { + XCTFail("the countdown did not rasterise") + return Data() + } + host.cacheDisplay(in: host.bounds, to: rep) + return rep.representation(using: .png, properties: [:]) ?? Data() + } + + // One host across the change, so the frame after it is a frame of the + // same view mid-transition, not a view built with the new text. + let host = makeHost() + let before = snapshot(host) + h.clock.advance(1) + manager.refreshCountdown() + XCTAssertEqual(manager.countdownText, "3:59:59") + // The very next frame, with no time for a spring to run. + let after = snapshot(host) + // And what that state looks like with no change to interpolate. + let fresh = snapshot(makeHost()) + + XCTAssertNotEqual(before, after, "the digits must change") + XCTAssertEqual(after, fresh, "the changed frame must already read the new digits, not roll to them") } /// The blink was over in a handful of frames and barely read. It has to diff --git a/docs/releasing.md b/docs/releasing.md index 9dd6d925..87770da3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -242,6 +242,13 @@ below cover what only a real Mac shows. - Settings floor steppers keep the end floor below the Low Power Mode floor by moving the other stepper - First launch over an existing install tightens Application Support/Insomnia and Logs/Insomnia to 0700 and their files to 0600 +### Menu bar + +- A session with the lid open holds the app near idle: start a session, wait + 10 s, then sample `ps -o %cpu= -p ` every 0.5 s for 20 s. The mean + stays under about 5%, and the countdown still changes every second. It read + about 46% before the countdown stopped animating its digits + ### Lid close - Lid-close/open and safe recovery of explicitly selected test processes