Skip to content

Commit b37c881

Browse files
author
Peter Klingelhofer
committed
feat: Screen tint button, stop works as expected
1 parent 4225d09 commit b37c881

File tree

8 files changed

+186
-86
lines changed

8 files changed

+186
-86
lines changed

README.md

+3-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Each of these implementations allows users to set an inhale, inhale hold, exhale
1212

1313
The information and guidance provided by this breathing app are intended for general informational purposes only and should not be construed as medical advice, diagnosis, or treatment. The creator of this app is not a medical professional, and the app is not a substitute for professional medical advice or consultation with a qualified healthcare provider. Always seek the advice of a physician or other qualified healthcare provider with any questions you may have regarding a medical condition or health objectives. Do not disregard or delay seeking professional medical advice because of the information or suggestions provided by this app. In the event of a medical emergency, call your doctor or dial your local emergency number immediately. Use of this app is at your own risk, and the creator assumes no responsibility for any adverse effects or consequences resulting from its use.
1414

15-
## Download
15+
## Download
1616

1717
You can download the build for your respective operating system on the [Releases](https://github.com/peterklingelhofer/exhale/releases) page. Using the latest release is recommended, but if you run into issues you could try a previous release to see if that yields better results. If you do encounter a problem, please [document the issue you encountered](https://github.com/peterklingelhofer/exhale/issues/new).
1818

@@ -24,9 +24,9 @@ You can download the build for your respective operating system on the [Releases
2424

2525
Note: This is built natively in Swift.
2626

27-
To launch the app on Catalina or newer, you may have to right click and select "Open" instead of double clicking on it. That's Apple's take on "security" for non-notarized binaries, or if you are not connected to the Internet.
27+
To launch the app on Catalina or newer for the first time, you may have to right click and select "Open" instead of double clicking on it, and you may need to do this twice. That's Apple's take on "security" for non-notarized binaries, or if you are not connected to the Internet.
2828

29-
You can use <kbd>Ctrl</kbd> + <kbd>,</kbd> to toggle settings open and closed. The **Pause** feature can be used to tint your screen or make your screen darker than otherwise possible for nighttime work (which can compound with both [Night Shift](https://support.apple.com/en-us/102191) and [f.lux](https://justgetflux.com/).
29+
You can use <kbd>Ctrl</kbd> + <kbd>,</kbd> to toggle settings open and closed. The **Tint** feature can be used to tint your screen the color of your selected background color, or make your screen darker than otherwise possible for nighttime work (which can compound with both [Night Shift](https://support.apple.com/en-us/102191) and [f.lux](https://justgetflux.com/).
3030

3131
```sh
3232
git clone https://github.com/peterklingelhofer/exhale.git
@@ -41,7 +41,6 @@ xed .
4141
![exhaleElectronCircular](https://user-images.githubusercontent.com/60944077/224865780-0e61721e-2345-49aa-830d-0e157b6f4366.gif)
4242
<img width="912" alt="Screenshot 2024-06-01 at 1 35 36 PM" src="https://github.com/peterklingelhofer/exhale/assets/60944077/b2eb9450-8dcf-4934-b6c9-08328ef6a167">
4343

44-
4544
Note: This implementation is built with TypeScript & Electron. The macOS will build but it is not very performant and is far more CPU-intensive than the native Swift build, and as a result the Swift build is recommended for macOS users.
4645

4746
```sh

swift/exhale.xcodeproj/project.pbxproj

+4-4
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@
446446
CODE_SIGN_ENTITLEMENTS = exhale/exhale.entitlements;
447447
CODE_SIGN_STYLE = Automatic;
448448
COMBINE_HIDPI_IMAGES = YES;
449-
CURRENT_PROJECT_VERSION = 150;
449+
CURRENT_PROJECT_VERSION = 151;
450450
DEAD_CODE_STRIPPING = YES;
451451
DEVELOPMENT_ASSET_PATHS = "\"exhale/Preview Content\"";
452452
DEVELOPMENT_TEAM = VZCHHV7VNW;
@@ -459,7 +459,7 @@
459459
"@executable_path/../Frameworks",
460460
);
461461
MACOSX_DEPLOYMENT_TARGET = 11.0;
462-
MARKETING_VERSION = 1.5.0;
462+
MARKETING_VERSION = 1.5.1;
463463
PRODUCT_BUNDLE_IDENTIFIER = peterklingelhofer.exhale;
464464
PRODUCT_NAME = "$(TARGET_NAME)";
465465
SUPPORTED_PLATFORMS = macosx;
@@ -478,7 +478,7 @@
478478
CODE_SIGN_ENTITLEMENTS = exhale/exhale.entitlements;
479479
CODE_SIGN_STYLE = Automatic;
480480
COMBINE_HIDPI_IMAGES = YES;
481-
CURRENT_PROJECT_VERSION = 150;
481+
CURRENT_PROJECT_VERSION = 151;
482482
DEAD_CODE_STRIPPING = YES;
483483
DEVELOPMENT_ASSET_PATHS = "\"exhale/Preview Content\"";
484484
DEVELOPMENT_TEAM = VZCHHV7VNW;
@@ -491,7 +491,7 @@
491491
"@executable_path/../Frameworks",
492492
);
493493
MACOSX_DEPLOYMENT_TARGET = 11.0;
494-
MARKETING_VERSION = 1.5.0;
494+
MARKETING_VERSION = 1.5.1;
495495
PRODUCT_BUNDLE_IDENTIFIER = peterklingelhofer.exhale;
496496
PRODUCT_NAME = "$(TARGET_NAME)";
497497
SUPPORTED_PLATFORMS = macosx;

swift/exhale/AppDelegate.swift

+52-26
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,59 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
1313
var isAnimatingSubscription: AnyCancellable?
1414
var subscriptions = Set<AnyCancellable>()
1515
var statusItem: NSStatusItem!
16-
16+
1717
func setUpStatusItem() {
1818
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
1919
if let button = statusItem.button {
2020
button.image = NSImage(named: "StatusBarIcon")
2121
button.action = #selector(statusBarButtonClicked(sender:))
2222
}
23-
23+
2424
let menu = NSMenu()
2525
menu.addItem(NSMenuItem(title: "Preferences...", action: #selector(toggleSettings(_:)), keyEquivalent: ","))
2626

27-
let startStopMenuItem = NSMenuItem(title: settingsModel.isAnimating ? "Stop" : "Start", action: #selector(toggleAnimating(_:)), keyEquivalent: "s")
28-
menu.addItem(startStopMenuItem)
27+
let startMenuItem = NSMenuItem(title: "Start", action: #selector(startAnimating(_:)), keyEquivalent: "s")
28+
let tintMenuItem = NSMenuItem(title: "Tint", action: #selector(pauseAnimating(_:)), keyEquivalent: "p")
29+
let stopMenuItem = NSMenuItem(title: "Stop", action: #selector(stopAnimating(_:)), keyEquivalent: "x")
2930

31+
menu.addItem(startMenuItem)
32+
menu.addItem(stopMenuItem)
33+
menu.addItem(tintMenuItem)
3034
menu.addItem(NSMenuItem(title: "Quit exhale", action: #selector(terminateApp(_:)), keyEquivalent: "q"))
31-
35+
3236
settingsModel.$isAnimating
33-
.sink { isAnimating in
34-
startStopMenuItem.title = isAnimating ? "Stop" : "Start"
37+
.sink { [weak self] isAnimating in
38+
guard let self = self else { return }
39+
startMenuItem.isEnabled = !isAnimating
40+
stopMenuItem.isEnabled = isAnimating || self.settingsModel.isPaused
41+
tintMenuItem.isEnabled = !isAnimating && !self.settingsModel.isPaused
42+
}
43+
.store(in: &subscriptions)
44+
45+
settingsModel.$isPaused
46+
.sink { [weak self] isPaused in
47+
guard let self = self else { return }
48+
tintMenuItem.isEnabled = !self.settingsModel.isAnimating && !isPaused
3549
}
3650
.store(in: &subscriptions)
3751

3852
statusItem.menu = menu
3953
}
4054

41-
@objc func toggleAnimating(_ sender: Any?) {
42-
settingsModel.isAnimating.toggle()
55+
@objc func startAnimating(_ sender: Any?) {
56+
settingsModel.start()
57+
}
58+
59+
@objc func stopAnimating(_ sender: Any?) {
60+
settingsModel.stop()
61+
}
62+
63+
@objc func pauseAnimating(_ sender: Any?) {
64+
if settingsModel.isPaused {
65+
settingsModel.unpause()
66+
} else {
67+
settingsModel.pause()
68+
}
4369
}
4470

4571
@objc func statusBarButtonClicked(sender: NSStatusBarButton) {
@@ -49,7 +75,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
4975
@objc func terminateApp(_ sender: Any?) {
5076
NSApp.terminate(nil)
5177
}
52-
78+
5379
func applicationDidFinishLaunching(_ notification: Notification) {
5480
NSApp.setActivationPolicy(.accessory)
5581
settingsModel = SettingsModel()
@@ -62,15 +88,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
6288
backing: .buffered,
6389
defer: false
6490
)
65-
91+
6692
window.contentView = NSHostingView(rootView: ContentView().environmentObject(settingsModel))
6793
window.makeKeyAndOrderFront(nil)
6894
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 1) // Window level in front of the menu bar
6995
window.alphaValue = CGFloat(settingsModel.overlayOpacity)
7096
window.isOpaque = false
7197
window.ignoresMouseEvents = true
7298
window.setFrame(screen.frame, display: true)
73-
99+
74100
windows.append(window)
75101
}
76102

@@ -79,33 +105,33 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
79105
window.backgroundColor = NSColor(newColor)
80106
}
81107
}
82-
108+
83109
exhaleColorSubscription = settingsModel.$exhaleColor.sink { [unowned self] newColor in
84110
for window in self.windows {
85111
window.backgroundColor = NSColor(newColor)
86112
}
87113
}
88-
114+
89115
overlayOpacitySubscription = settingsModel.$overlayOpacity.sink { [unowned self] newOpacity in
90116
for window in self.windows {
91117
window.alphaValue = CGFloat(newOpacity)
92118
}
93119
}
94-
120+
95121
// Reload content view when any setting changes
96122
settingsModel.objectWillChange.sink { [unowned self] in
97123
self.reloadContentView()
98124
}.store(in: &subscriptions)
99-
125+
100126
reloadContentView()
101-
127+
102128
settingsWindow = NSWindow(
103129
contentRect: NSRect(x: 0, y: 0, width: 600, height: 200),
104130
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
105131
backing: .buffered,
106132
defer: false
107133
)
108-
134+
109135
settingsWindow.delegate = self
110136
settingsWindow.contentView = NSHostingView(rootView: SettingsView(
111137
showSettings: .constant(false),
@@ -120,31 +146,31 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
120146
drift: Binding(get: { self.settingsModel.drift }, set: { self.settingsModel.drift = $0 }),
121147
overlayOpacity: Binding(get: { self.settingsModel.overlayOpacity }, set: { self.settingsModel.overlayOpacity = $0 }),
122148
shape: Binding<AnimationShape>(get: { self.settingsModel.shape }, set: { self.settingsModel.shape = $0 }),
123-
animationMode: Binding<AnimationMode>(get: { self.settingsModel.animationMode }, set: { self.settingsModel.animationMode = $0 }),
149+
animationMode: Binding(get: { self.settingsModel.animationMode }, set: { self.settingsModel.animationMode = $0 }),
124150
randomizedTimingInhale: Binding(get: { self.settingsModel.randomizedTimingInhale }, set: { self.settingsModel.randomizedTimingInhale = $0 }),
125151
randomizedTimingPostInhaleHold: Binding(get: { self.settingsModel.randomizedTimingPostInhaleHold }, set: { self.settingsModel.randomizedTimingPostInhaleHold = $0 }),
126152
randomizedTimingExhale: Binding(get: { self.settingsModel.randomizedTimingExhale }, set: { self.settingsModel.randomizedTimingExhale = $0 }),
127153
randomizedTimingPostExhaleHold: Binding(get: { self.settingsModel.randomizedTimingPostExhaleHold }, set: { self.settingsModel.randomizedTimingPostExhaleHold = $0 }),
128154
isAnimating: Binding(get: { self.settingsModel.isAnimating }, set: { self.settingsModel.isAnimating = $0 })
129155
).environmentObject(settingsModel))
130-
156+
131157
settingsWindow.title = "exhale"
132158
toggleSettings(nil)
133159
setUpStatusItem()
134-
160+
135161
isAnimatingSubscription = settingsModel.$isAnimating.sink { [unowned self] isAnimating in
136-
if !isAnimating {
162+
if !isAnimating && !self.settingsModel.isPaused {
137163
for window in self.windows {
138164
window.backgroundColor = NSColor.clear
139165
}
140166
}
141167
}
142168
}
143-
169+
144170
func applicationWillTerminate(_ notification: Notification) {
145171
// Insert code here to tear down your application
146172
}
147-
173+
148174
@objc func toggleSettings(_ sender: Any?) {
149175
if settingsWindow.isVisible {
150176
settingsWindow.orderOut(nil)
@@ -154,14 +180,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
154180
settingsWindow.level = .floating
155181
}
156182
}
157-
183+
158184
func reloadContentView() {
159185
let contentView = ContentView().environmentObject(settingsModel)
160186
for window in windows {
161187
window.contentView = NSHostingView(rootView: contentView)
162188
}
163189
}
164-
190+
165191
func windowShouldClose(_ sender: NSWindow) -> Bool {
166192
if sender == settingsWindow {
167193
settingsWindow.orderOut(sender)

swift/exhale/ContentView.swift

+41-14
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,21 @@ struct ContentView: View {
5757
@State private var overlayOpacity: Double = 0.1
5858
@State private var showSettings = false
5959
@State private var cycleCount: Int = 0
60-
60+
6161
var maxCircleScale: CGFloat {
6262
guard let screen = NSScreen.main else { return settingsModel.colorFillGradient == .on ? 2 : 1 }
6363
let screenWidth = screen.frame.width
6464
let screenHeight = screen.frame.height
6565
let maxDimension = max(screenWidth, screenHeight)
6666
return maxDimension / min(screenWidth, screenHeight)
6767
}
68-
68+
6969
var body: some View {
7070
ZStack {
7171
GeometryReader { geometry in
7272
ZStack {
73-
if !settingsModel.isAnimating {
74-
Color.clear.edgesIgnoringSafeArea(.all)
73+
if !settingsModel.isAnimating && !settingsModel.isPaused {
74+
Color.clear.edgesIgnoringSafeArea(.all)
7575
} else {
7676
settingsModel.backgroundColor.edgesIgnoringSafeArea(.all)
7777

@@ -134,21 +134,28 @@ struct ContentView: View {
134134
resetAnimation()
135135
}
136136
}
137+
.onChange(of: settingsModel.isPaused) { newValue in
138+
if newValue {
139+
stopCurrentAnimation()
140+
} else if settingsModel.isAnimating {
141+
resumeBreathingCycle()
142+
}
143+
}
137144
.onChange(of: settingsModel.resetAnimation) { newValue in
138145
if newValue {
139146
resetAnimation()
140147
startBreathingCycle()
141148
}
142149
}
143150
}
144-
151+
145152
func startBreathingCycle() {
146153
cycleCount = 0
147154
inhale()
148155
}
149-
156+
150157
func inhale() {
151-
guard settingsModel.isAnimating else { return resetAnimation() }
158+
guard settingsModel.isAnimating && !settingsModel.isPaused else { return }
152159
var duration = settingsModel.inhaleDuration * pow(settingsModel.drift, Double(cycleCount))
153160
if settingsModel.randomizedTimingInhale > 0 {
154161
duration += Double.random(in: -settingsModel.randomizedTimingInhale...settingsModel.randomizedTimingInhale)
@@ -168,9 +175,9 @@ struct ContentView: View {
168175
holdAfterInhale()
169176
}
170177
}
171-
178+
172179
func holdAfterInhale() {
173-
guard settingsModel.isAnimating else { return resetAnimation() }
180+
guard settingsModel.isAnimating && !settingsModel.isPaused else { return }
174181
var duration = settingsModel.postInhaleHoldDuration * pow(settingsModel.drift, Double(cycleCount))
175182
if settingsModel.randomizedTimingPostInhaleHold > 0 {
176183
duration += Double.random(in: -settingsModel.randomizedTimingPostInhaleHold...settingsModel.randomizedTimingPostInhaleHold)
@@ -181,9 +188,9 @@ struct ContentView: View {
181188
exhale()
182189
}
183190
}
184-
191+
185192
func exhale() {
186-
guard settingsModel.isAnimating else { return resetAnimation() }
193+
guard settingsModel.isAnimating && !settingsModel.isPaused else { return }
187194
var duration = settingsModel.exhaleDuration * pow(settingsModel.drift, Double(cycleCount))
188195
if settingsModel.randomizedTimingExhale > 0 {
189196
duration += Double.random(in: -settingsModel.randomizedTimingExhale...settingsModel.randomizedTimingExhale)
@@ -200,9 +207,9 @@ struct ContentView: View {
200207
holdAfterExhale()
201208
}
202209
}
203-
210+
204211
func holdAfterExhale() {
205-
guard settingsModel.isAnimating else { return resetAnimation() }
212+
guard settingsModel.isAnimating && !settingsModel.isPaused else { return }
206213
var duration = settingsModel.postExhaleHoldDuration * pow(settingsModel.drift, Double(cycleCount))
207214
if settingsModel.randomizedTimingPostExhaleHold > 0 {
208215
duration += Double.random(in: -settingsModel.randomizedTimingPostExhaleHold...settingsModel.randomizedTimingPostExhaleHold)
@@ -216,12 +223,32 @@ struct ContentView: View {
216223
self.inhale()
217224
}
218225
}
219-
226+
220227
func resetAnimation() {
221228
cycleCount = 0
222229
animationProgress = 0.0
223230
breathingPhase = .inhale
224231
}
232+
233+
func stopCurrentAnimation() {
234+
// Stop the current animation
235+
cycleCount = 0
236+
animationProgress = 0.0
237+
}
238+
239+
func resumeBreathingCycle() {
240+
// Resume the breathing cycle
241+
switch breathingPhase {
242+
case .inhale:
243+
inhale()
244+
case .holdAfterInhale:
245+
holdAfterInhale()
246+
case .exhale:
247+
exhale()
248+
case .holdAfterExhale:
249+
holdAfterExhale()
250+
}
251+
}
225252
}
226253

227254
struct ContentView_Previews: PreviewProvider {

0 commit comments

Comments
 (0)