Skip to content

Commit 1d5978c

Browse files
Merge pull request #99 from peterklingelhofer/feature/Reduce-CPU
feat: Reduce CPU delta from 5.3% to 3.5% - triggerAnimationReset() no…
2 parents da8d44f + 23e1fb0 commit 1d5978c

5 files changed

Lines changed: 69 additions & 107 deletions

File tree

swift/exhale.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@
490490
CODE_SIGN_ENTITLEMENTS = exhale/exhale.entitlements;
491491
CODE_SIGN_STYLE = Automatic;
492492
COMBINE_HIDPI_IMAGES = YES;
493-
CURRENT_PROJECT_VERSION = 206;
493+
CURRENT_PROJECT_VERSION = 207;
494494
DEAD_CODE_STRIPPING = YES;
495495
DEVELOPMENT_ASSET_PATHS = "\"exhale/Preview Content\"";
496496
DEVELOPMENT_TEAM = VZCHHV7VNW;
@@ -505,7 +505,7 @@
505505
"@executable_path/../Frameworks",
506506
);
507507
MACOSX_DEPLOYMENT_TARGET = 11.0;
508-
MARKETING_VERSION = 2.0.6;
508+
MARKETING_VERSION = 2.0.7;
509509
PRODUCT_BUNDLE_IDENTIFIER = peterklingelhofer.exhale;
510510
PRODUCT_NAME = "$(TARGET_NAME)";
511511
SUPPORTED_PLATFORMS = macosx;
@@ -524,7 +524,7 @@
524524
CODE_SIGN_ENTITLEMENTS = exhale/exhale.entitlements;
525525
CODE_SIGN_STYLE = Automatic;
526526
COMBINE_HIDPI_IMAGES = YES;
527-
CURRENT_PROJECT_VERSION = 206;
527+
CURRENT_PROJECT_VERSION = 207;
528528
DEAD_CODE_STRIPPING = YES;
529529
DEVELOPMENT_ASSET_PATHS = "\"exhale/Preview Content\"";
530530
DEVELOPMENT_TEAM = VZCHHV7VNW;
@@ -539,7 +539,7 @@
539539
"@executable_path/../Frameworks",
540540
);
541541
MACOSX_DEPLOYMENT_TARGET = 11.0;
542-
MARKETING_VERSION = 2.0.6;
542+
MARKETING_VERSION = 2.0.7;
543543
PRODUCT_BUNDLE_IDENTIFIER = peterklingelhofer.exhale;
544544
PRODUCT_NAME = "$(TARGET_NAME)";
545545
SUPPORTED_PLATFORMS = macosx;

swift/exhale/AppDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
293293

294294
@objc func raiseTooltipWindows(_ notification: Notification?) {
295295
guard settingsWindow.isVisible else { return }
296-
for window in NSApp.windows where String(describing: type(of: window)).contains("ToolTip") {
296+
for window in NSApp.windows where NSStringFromClass(type(of: window)).contains("ToolTip") {
297297
if window.level != Self.tooltipWindowLevel {
298298
window.level = Self.tooltipWindowLevel
299299
}

swift/exhale/ContentView.swift

Lines changed: 21 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,6 @@ struct ContentView: View {
104104
@EnvironmentObject var settingsModel: SettingsModel
105105
@State private var animationProgress: CGFloat = 0
106106
@State private var breathingPhase: BreathingPhase = .inhale
107-
@State private var overlayOpacity: Double = 0.1
108-
@State private var showSettings = false
109107
@State private var cycleCount: Int = 0
110108
@State private var cachedMaxCircleScale: CGFloat = 1
111109
@State private var animationSessionIdentifier: Int = 0
@@ -203,18 +201,24 @@ struct ContentView: View {
203201
let blurRadius = useGradient ? borderUnit * 2 : 0 as CGFloat
204202

205203
Group {
206-
ForEach([true, false], id: \.self) { rightSide in
207-
// Trail glow (fills behind the sweep front)
208-
HalfPerimeterShape(rightSide: rightSide)
209-
.trim(from: trailFrom, to: trailTo)
210-
.stroke(phaseColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .butt))
211-
.opacity(0.25)
212-
// Leading band (bright sweep at the front)
213-
HalfPerimeterShape(rightSide: rightSide)
214-
.trim(from: bandFrom, to: bandTo)
215-
.stroke(phaseColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .butt))
216-
.opacity(0.8)
217-
}
204+
// Trail glow (fills behind the sweep front)
205+
HalfPerimeterShape(rightSide: true)
206+
.trim(from: trailFrom, to: trailTo)
207+
.stroke(phaseColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .butt))
208+
.opacity(0.25)
209+
HalfPerimeterShape(rightSide: false)
210+
.trim(from: trailFrom, to: trailTo)
211+
.stroke(phaseColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .butt))
212+
.opacity(0.25)
213+
// Leading band (bright sweep at the front)
214+
HalfPerimeterShape(rightSide: true)
215+
.trim(from: bandFrom, to: bandTo)
216+
.stroke(phaseColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .butt))
217+
.opacity(0.8)
218+
HalfPerimeterShape(rightSide: false)
219+
.trim(from: bandFrom, to: bandTo)
220+
.stroke(phaseColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .butt))
221+
.opacity(0.8)
218222
}
219223
.blur(radius: blurRadius)
220224
.opacity(rippleOpacity)
@@ -223,38 +227,6 @@ struct ContentView: View {
223227
}
224228
}
225229

226-
if showSettings {
227-
SettingsView(
228-
showSettings: $showSettings,
229-
inhaleColor: $settingsModel.inhaleColor,
230-
exhaleColor: $settingsModel.exhaleColor,
231-
backgroundColor: $settingsModel.backgroundColor,
232-
colorFillType: $settingsModel.colorFillGradient,
233-
inhaleDuration: $settingsModel.inhaleDuration,
234-
postInhaleHoldDuration: $settingsModel.postInhaleHoldDuration,
235-
exhaleDuration: $settingsModel.exhaleDuration,
236-
postExhaleHoldDuration: $settingsModel.postExhaleHoldDuration,
237-
drift: $settingsModel.drift,
238-
overlayOpacity: $overlayOpacity,
239-
shape: Binding<AnimationShape>(
240-
get: { self.settingsModel.shape },
241-
set: { self.settingsModel.shape = $0 }
242-
),
243-
animationMode: Binding<AnimationMode>(
244-
get: { self.settingsModel.animationMode },
245-
set: { self.settingsModel.animationMode = $0 }
246-
),
247-
randomizedTimingInhale: $settingsModel.randomizedTimingInhale,
248-
randomizedTimingPostInhaleHold: $settingsModel.randomizedTimingPostInhaleHold,
249-
randomizedTimingExhale: $settingsModel.randomizedTimingExhale,
250-
randomizedTimingPostExhaleHold: $settingsModel.randomizedTimingPostExhaleHold,
251-
holdRippleMode: $settingsModel.holdRippleMode,
252-
isAnimating: $settingsModel.isAnimating,
253-
appVisibility: $settingsModel.appVisibility,
254-
reminderIntervalMinutes: $settingsModel.reminderIntervalMinutes,
255-
autoStopMinutes: $settingsModel.autoStopMinutes
256-
)
257-
}
258230
}
259231
.onAppear {
260232
cachedMaxCircleScale = Self.getMaxCircleScale()
@@ -276,11 +248,9 @@ struct ContentView: View {
276248
resumeBreathingCycle()
277249
}
278250
}
279-
.onChange(of: settingsModel.resetAnimation) { newValue in
280-
if newValue {
281-
resetAnimation()
282-
startBreathingCycle()
283-
}
251+
.onReceive(settingsModel.resetAnimationSignal) { _ in
252+
resetAnimation()
253+
startBreathingCycle()
284254
}
285255
.onChange(of: settingsModel.shape) { _ in
286256
guard settingsModel.isAnimating && !settingsModel.isPaused else { return }

swift/exhale/SettingsModel.swift

Lines changed: 29 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -47,34 +47,27 @@ class SettingsModel: ObservableObject {
4747
}
4848
}
4949

50-
@Published var inhaleDuration: TimeInterval {
51-
didSet {
52-
defaults.set(inhaleDuration, forKey: "inhaleDuration")
53-
}
50+
// Scheduling-only properties: not @Published because they don't affect what's
51+
// rendered per-frame. Removing @Published prevents overlay ContentViews from
52+
// re-rendering when the user adjusts timing in settings.
53+
var inhaleDuration: TimeInterval {
54+
didSet { defaults.set(inhaleDuration, forKey: "inhaleDuration") }
5455
}
5556

56-
@Published var postInhaleHoldDuration: TimeInterval {
57-
didSet {
58-
defaults.set(postInhaleHoldDuration, forKey: "postInhaleHoldDuration")
59-
}
57+
var postInhaleHoldDuration: TimeInterval {
58+
didSet { defaults.set(postInhaleHoldDuration, forKey: "postInhaleHoldDuration") }
6059
}
6160

62-
@Published var exhaleDuration: TimeInterval {
63-
didSet {
64-
defaults.set(exhaleDuration, forKey: "exhaleDuration")
65-
}
61+
var exhaleDuration: TimeInterval {
62+
didSet { defaults.set(exhaleDuration, forKey: "exhaleDuration") }
6663
}
6764

68-
@Published var postExhaleHoldDuration: TimeInterval {
69-
didSet {
70-
defaults.set(postExhaleHoldDuration, forKey: "postExhaleHoldDuration")
71-
}
65+
var postExhaleHoldDuration: TimeInterval {
66+
didSet { defaults.set(postExhaleHoldDuration, forKey: "postExhaleHoldDuration") }
7267
}
7368

74-
@Published var drift: Double {
75-
didSet {
76-
defaults.set(drift, forKey: "drift")
77-
}
69+
var drift: Double {
70+
didSet { defaults.set(drift, forKey: "drift") }
7871
}
7972

8073
@Published var overlayOpacity: Double {
@@ -95,10 +88,8 @@ class SettingsModel: ObservableObject {
9588
}
9689
}
9790

98-
@Published var animationMode: AnimationMode {
99-
didSet {
100-
defaults.set(animationMode.rawValue, forKey: "animationMode")
101-
}
91+
var animationMode: AnimationMode {
92+
didSet { defaults.set(animationMode.rawValue, forKey: "animationMode") }
10293
}
10394

10495
@Published var appVisibility: AppVisibility {
@@ -121,28 +112,20 @@ class SettingsModel: ObservableObject {
121112
}
122113
}
123114

124-
@Published var randomizedTimingInhale: Double {
125-
didSet {
126-
defaults.set(randomizedTimingInhale, forKey: "randomizedTimingInhale")
127-
}
115+
var randomizedTimingInhale: Double {
116+
didSet { defaults.set(randomizedTimingInhale, forKey: "randomizedTimingInhale") }
128117
}
129118

130-
@Published var randomizedTimingPostInhaleHold: Double {
131-
didSet {
132-
defaults.set(randomizedTimingPostInhaleHold, forKey: "randomizedTimingPostInhaleHold")
133-
}
119+
var randomizedTimingPostInhaleHold: Double {
120+
didSet { defaults.set(randomizedTimingPostInhaleHold, forKey: "randomizedTimingPostInhaleHold") }
134121
}
135122

136-
@Published var randomizedTimingExhale: Double {
137-
didSet {
138-
defaults.set(randomizedTimingExhale, forKey: "randomizedTimingExhale")
139-
}
123+
var randomizedTimingExhale: Double {
124+
didSet { defaults.set(randomizedTimingExhale, forKey: "randomizedTimingExhale") }
140125
}
141126

142-
@Published var randomizedTimingPostExhaleHold: Double {
143-
didSet {
144-
defaults.set(randomizedTimingPostExhaleHold, forKey: "randomizedTimingPostExhaleHold")
145-
}
127+
var randomizedTimingPostExhaleHold: Double {
128+
didSet { defaults.set(randomizedTimingPostExhaleHold, forKey: "randomizedTimingPostExhaleHold") }
146129
}
147130

148131
@Published var holdRippleMode: HoldRippleMode {
@@ -161,7 +144,11 @@ class SettingsModel: ObservableObject {
161144
}
162145
}
163146

164-
@Published var resetAnimation: Bool = false
147+
/// Fires once when the animation should reset. Using a PassthroughSubject instead
148+
/// of @Published Bool prevents the double objectWillChange fire that @Published causes
149+
/// (true then false), which was forcing all overlay ContentViews to re-render twice
150+
/// per settings change.
151+
let resetAnimationSignal = PassthroughSubject<Void, Never>()
165152
@Published var isPaused: Bool = false
166153

167154
var inhaleAndExhaleColorsMatch: Bool {
@@ -172,8 +159,7 @@ class SettingsModel: ObservableObject {
172159
}
173160

174161
func triggerAnimationReset() {
175-
resetAnimation = true
176-
resetAnimation = false
162+
resetAnimationSignal.send()
177163
}
178164

179165
func start() {

swift/exhaleTests/exhaleTests.swift

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,18 +1569,24 @@ class PerformanceTests: XCTestCase {
15691569
print(" animating: [\(animStr)]")
15701570
print(" delta: [\(deltaStr)] avg: \(String(format: "%.1f", avgDelta))% peak: \(String(format: "%.1f", peakDelta))%")
15711571

1572-
// CI VMs have noisier CPU; use relaxed thresholds when running on GitHub Actions.
1573-
// Ripple tests run blur + continuous hold animation alongside the main shape, so
1574-
// they need slightly higher limits.
1572+
// Thresholds are set at ~2x the measured values to catch regressions without
1573+
// false positives. CI VMs are noisier so get an additional buffer.
1574+
// Ripple tests exercise blur + continuous animation concurrently.
1575+
// Measured baselines (2026-03-28):
1576+
// no-ripple: avg ≤ 2.6%, peak ≤ 2.9%
1577+
// ripple: avg ≤ 2.4%, peak ≤ 3.5%
15751578
let isCI = ProcessInfo.processInfo.environment["CI"] != nil
15761579
let hasRipple = holdRipple != .off
1577-
let peakThreshold: Double = isCI ? 20.0 : (hasRipple ? 15.0 : 10.0)
1578-
let avgThreshold: Double = isCI ? 12.0 : (hasRipple ? 10.0 : 5.0)
1580+
let peakThreshold: Double = isCI ? (hasRipple ? 15.0 : 12.0) : (hasRipple ? 10.0 : 8.0)
1581+
let avgThreshold: Double = isCI ? (hasRipple ? 10.0 : 8.0 ) : (hasRipple ? 8.0 : 6.0)
15791582

15801583
// Assert animation cost (above baseline) stays under thresholds.
1581-
// Local: peak < 10%, average < 5%.
1582-
// Local+ripple: peak < 15%, average < 10%.
1583-
// CI: peak < 20%, average < 12%.
1584+
// Local (no ripple): peak < 8%, avg < 6%.
1585+
// Local (ripple): peak < 10%, avg < 8%.
1586+
// CI (no ripple): peak < 12%, avg < 8%.
1587+
// CI (ripple): peak < 15%, avg < 10%.
1588+
// Circle gradient is inherently noisier than rectangle because its RadialGradient
1589+
// endRadius changes every frame, making it more sensitive to system load variance.
15841590
XCTAssertLessThan(peakDelta, peakThreshold,
15851591
"\(label) peak animation CPU \(String(format: "%.1f", peakDelta))% exceeded \(String(format: "%.0f", peakThreshold))% — delta: [\(deltaStr)]",
15861592
file: file, line: line

0 commit comments

Comments
 (0)