Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions Sources/Insomnia/UI/Motion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions Sources/Insomnia/UI/StatusRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
armagg marked this conversation as resolved.
.padding(.trailing, 1)
.transition(Motion.countdownTransition(reduceMotion: reduceMotion))
.contentShape(Rectangle())
Expand Down
79 changes: 76 additions & 3 deletions Tests/InsomniaTests/UIStatusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pid>` 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
Expand Down
Loading