(value: dict[kCGWindowMemoryUsage] as! Double, unit: .bytes),
66 | windowNumber: dict[kCGWindowNumber] as! Int,
67 | sharingState: CGWindowSharingType(rawValue: dict[kCGWindowSharingState] as! UInt32)!,
68 | backingStoreType: CGWindowBackingType(rawValue: dict[kCGWindowStoreType] as! UInt32)!,
69 | otherAttributes: otherAttributes
70 | )
71 | }
72 | }
73 |
74 | extension WindowInfo {
75 | var processRelatedWindows: [WindowInfo] {
76 | WindowInfo.allOnScreenWindows
77 | .filter { $0.ownerProcessID == ownerProcessID }
78 | .filter { $0.windowNumber != windowNumber }
79 | }
80 |
81 | var containsMouse: Bool {
82 | // Change mouse coordinate (with an upside y-axis) to screen coordinate (with a down y-axis)
83 | let mouseInScreen = NSEvent.mouseLocation
84 | .applying(.init(translationX: 0, y: -ScreenManager.frame.height))
85 | .applying(.init(scaleX: 1, y: -1))
86 |
87 | return bounds.contains(mouseInScreen)
88 | }
89 |
90 | func isPlacingNear(_ rect: NSRect, edge: NSRectEdge) -> Bool {
91 | let gap: Double = 25
92 | switch edge {
93 | case .minX:
94 | let verticallyOK = inGap(bounds.maxX, rect.minX, gap: gap)
95 | let horizontallyOK = bounds.minY <= rect.minY && bounds.maxY >= rect.maxY
96 | return verticallyOK && horizontallyOK
97 | case .minY:
98 | let verticallyOK = inGap(bounds.maxY, rect.minY, gap: gap)
99 | let horizontallyOK = bounds.minX <= rect.minX && bounds.maxX >= rect.maxX
100 | return verticallyOK && horizontallyOK
101 | case .maxX:
102 | let verticallyOK = inGap(bounds.minX, rect.maxX, gap: gap)
103 | let horizontallyOK = bounds.minY <= rect.minY && bounds.maxY >= rect.maxY
104 | return verticallyOK && horizontallyOK
105 | case .maxY:
106 | let verticallyOK = inGap(bounds.minY, rect.maxY, gap: gap)
107 | let horizontallyOK = bounds.minX <= rect.minX && bounds.maxX >= rect.maxX
108 | return verticallyOK && horizontallyOK
109 | @unknown default:
110 | return false
111 | }
112 | }
113 |
114 | private func inGap(_ a: Double, _ b: Double, gap: Double) -> Bool {
115 | abs(a - b) <= abs(gap)
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/Abyssal/mul.lproj/Main.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 | "08v-Vw-Cs3.title" : {
5 | "comment" : "Class = \"NSTextFieldCell\"; title = \"Starts with macOS\"; ObjectID = \"08v-Vw-Cs3\";",
6 | "extractionState" : "stale",
7 | "localizations" : {
8 | "en" : {
9 | "stringUnit" : {
10 | "state" : "new",
11 | "value" : "Starts with macOS"
12 | }
13 | },
14 | "zh-Hans" : {
15 | "stringUnit" : {
16 | "state" : "translated",
17 | "value" : "随macOS启动"
18 | }
19 | }
20 | }
21 | },
22 | "AYu-sK-qS6.title" : {
23 | "comment" : "Class = \"NSMenu\"; title = \"Main Menu\"; ObjectID = \"AYu-sK-qS6\";",
24 | "extractionState" : "extracted_with_value",
25 | "localizations" : {
26 | "en" : {
27 | "stringUnit" : {
28 | "state" : "new",
29 | "value" : "Main Menu"
30 | }
31 | },
32 | "zh-Hans" : {
33 | "stringUnit" : {
34 | "state" : "translated",
35 | "value" : ""
36 | }
37 | }
38 | }
39 | },
40 | "BCa-un-ZBt.title" : {
41 | "comment" : "Class = \"NSButtonCell\"; title = \"Quit app\"; ObjectID = \"BCa-un-ZBt\";",
42 | "extractionState" : "stale",
43 | "localizations" : {
44 | "en" : {
45 | "stringUnit" : {
46 | "state" : "new",
47 | "value" : "Quit app"
48 | }
49 | },
50 | "zh-Hans" : {
51 | "stringUnit" : {
52 | "state" : "translated",
53 | "value" : "退出应用"
54 | }
55 | }
56 | }
57 | },
58 | "CAQ-b2-2Ky.title" : {
59 | "comment" : "Class = \"NSButtonCell\"; title = \"Tips\"; ObjectID = \"CAQ-b2-2Ky\";",
60 | "extractionState" : "stale",
61 | "localizations" : {
62 | "en" : {
63 | "stringUnit" : {
64 | "state" : "new",
65 | "value" : "Tips"
66 | }
67 | },
68 | "zh-Hans" : {
69 | "stringUnit" : {
70 | "state" : "translated",
71 | "value" : "提示"
72 | }
73 | }
74 | }
75 | },
76 | "E4B-vn-v6T.title" : {
77 | "comment" : "Class = \"NSTextFieldCell\"; title = \"Feedback intensity\"; ObjectID = \"E4B-vn-v6T\";",
78 | "extractionState" : "stale",
79 | "localizations" : {
80 | "en" : {
81 | "stringUnit" : {
82 | "state" : "new",
83 | "value" : "Feedback intensity"
84 | }
85 | },
86 | "zh-Hans" : {
87 | "stringUnit" : {
88 | "state" : "translated",
89 | "value" : "反馈强度"
90 | }
91 | }
92 | }
93 | },
94 | "f3z-uV-2YB.title" : {
95 | "comment" : "Class = \"NSTextFieldCell\"; title = \"Reduce animation\"; ObjectID = \"f3z-uV-2YB\";",
96 | "extractionState" : "stale",
97 | "localizations" : {
98 | "en" : {
99 | "stringUnit" : {
100 | "state" : "new",
101 | "value" : "Reduce animation"
102 | }
103 | },
104 | "zh-Hans" : {
105 | "stringUnit" : {
106 | "state" : "translated",
107 | "value" : "减少动画"
108 | }
109 | }
110 | }
111 | },
112 | "H6v-Qm-J1v.title" : {
113 | "comment" : "Class = \"NSTextFieldCell\"; title = \"Theme\"; ObjectID = \"H6v-Qm-J1v\";",
114 | "extractionState" : "stale",
115 | "localizations" : {
116 | "en" : {
117 | "stringUnit" : {
118 | "state" : "new",
119 | "value" : "Theme"
120 | }
121 | },
122 | "zh-Hans" : {
123 | "stringUnit" : {
124 | "state" : "translated",
125 | "value" : "主题"
126 | }
127 | }
128 | }
129 | },
130 | "hml-Gq-OgY.title" : {
131 | "comment" : "Class = \"NSTextFieldCell\"; title = \"Timeout\"; ObjectID = \"hml-Gq-OgY\";",
132 | "extractionState" : "stale",
133 | "localizations" : {
134 | "en" : {
135 | "stringUnit" : {
136 | "state" : "new",
137 | "value" : "Timeout"
138 | }
139 | },
140 | "zh-Hans" : {
141 | "stringUnit" : {
142 | "state" : "translated",
143 | "value" : "超时"
144 | }
145 | }
146 | }
147 | },
148 | "MUA-YQ-vfC.title" : {
149 | "comment" : "Class = \"NSTextFieldCell\"; title = \"Dead zone\"; ObjectID = \"MUA-YQ-vfC\";",
150 | "extractionState" : "stale",
151 | "localizations" : {
152 | "en" : {
153 | "stringUnit" : {
154 | "state" : "new",
155 | "value" : "Dead zone"
156 | }
157 | },
158 | "zh-Hans" : {
159 | "stringUnit" : {
160 | "state" : "translated",
161 | "value" : "盲区宽度"
162 | }
163 | }
164 | }
165 | },
166 | "nbo-M8-Xyb.title" : {
167 | "comment" : "Class = \"NSTextFieldCell\"; title = \"to trigger\"; ObjectID = \"nbo-M8-Xyb\";",
168 | "extractionState" : "stale",
169 | "localizations" : {
170 | "en" : {
171 | "stringUnit" : {
172 | "state" : "new",
173 | "value" : "to trigger"
174 | }
175 | },
176 | "zh-Hans" : {
177 | "stringUnit" : {
178 | "state" : "translated",
179 | "value" : "修饰键以触发"
180 | }
181 | }
182 | }
183 | },
184 | "o5n-Ec-8Il.title" : {
185 | "comment" : "Class = \"NSTextFieldCell\"; title = \"Press\"; ObjectID = \"o5n-Ec-8Il\";",
186 | "extractionState" : "stale",
187 | "localizations" : {
188 | "en" : {
189 | "stringUnit" : {
190 | "state" : "new",
191 | "value" : "Press"
192 | }
193 | },
194 | "zh-Hans" : {
195 | "stringUnit" : {
196 | "state" : "translated",
197 | "value" : "按下"
198 | }
199 | }
200 | }
201 | },
202 | "ots-RU-Cm0.title" : {
203 | "comment" : "Class = \"NSTextFieldCell\"; title = \"Auto shows\"; ObjectID = \"ots-RU-Cm0\";",
204 | "extractionState" : "stale",
205 | "localizations" : {
206 | "en" : {
207 | "stringUnit" : {
208 | "state" : "new",
209 | "value" : "Auto shows"
210 | }
211 | },
212 | "zh-Hans" : {
213 | "stringUnit" : {
214 | "state" : "translated",
215 | "value" : "自动显示"
216 | }
217 | }
218 | }
219 | },
220 | "TpP-gd-jMY.title" : {
221 | "comment" : "Class = \"NSTextFieldCell\"; title = \"Use always hide area\"; ObjectID = \"TpP-gd-jMY\";",
222 | "extractionState" : "stale",
223 | "localizations" : {
224 | "en" : {
225 | "stringUnit" : {
226 | "state" : "new",
227 | "value" : "Use always hide area"
228 | }
229 | },
230 | "zh-Hans" : {
231 | "stringUnit" : {
232 | "state" : "translated",
233 | "value" : "使用永远隐藏区域"
234 | }
235 | }
236 | }
237 | }
238 | },
239 | "version" : "1.0"
240 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | あ ←→ A
5 |
6 |
7 | Abyssal supports the following languages. ↗ Add a localization
8 |
9 |
10 |
11 | English
12 |
13 | 简体中文
14 |
15 |
16 |
17 | ###
18 |
19 | # 
Abyssal
20 |
21 | ###### Simplify, tidy and master your macOS menu bar[^menu_bar].
22 |
23 | [^menu_bar]: Also known as _Status Bar._
24 |
25 | ###
26 |
27 | > [!IMPORTANT]
28 | >
29 | > **Abyssal** requires **macOS 14.0 Sonoma**[^check_your_macos_version] or above to run.
30 |
31 | [^check_your_macos_version]: [`↗ Find out which macOS your Mac is using`](https://support.apple.com/en-us/HT201260)
32 |
33 | ## Introduction & Usage
34 |
35 |
36 |

37 |
38 |
39 | ### Fundamentals
40 |
41 | **Abyssal** divides your menu bar into three areas - the **Always Hide Area,** the **Hide Area** and the **Visible Area:**
42 |
43 | - The **Always Hide Area** Icons inside this area will be _hided forever,_ unless you menually check them.
44 | - The **Hide Area** Icons inside this area follow certain rules. More often than not, you _don't see them._
45 | - The **Visible Area** Icons inside this area suffer no restrictions. You can see them _all the time._
46 |
47 | The three areas are separated by two separators - the `Always Hide Separator`
(the furthest one from the screen corner) and the `Hide Separator`
(the middle one). Apart from these, there's another separator on the nearest side to the screen corner - the `Menu Separator`
, which's position doesn't matter, but plays an important role.
48 |
49 | > **Abyssal** will automatically judge the order of the three separators, which means you don't need to care much about their position. For example, you are allowed to put the `Menu Separator` to the other side of the `Always Hide Separator`, as they will automatically swap their roles to the correct ones after your operation.
50 |
51 |
52 |
53 | ### Showing & Moving the Separators
54 |
55 | In many themes including the default theme, the separators are invisible (transparent) by default. If you _open the menu,_ or _move your cursor onto the menu bar[^cursor_onto_status_bar] and press the chosen modifiers,_ the separators will be visible (partly opaque). In the rest of the themes, the separators will always be visible, but their appearance may change automatically according to the status of **Abyssal**
56 |
57 | The visibilities of the separators can also indicate:
58 |
59 | - When using themes that automatically hide the separators, the `Menu Separator` will indicate the visibility of the status icons inside the **Hide Area**. If the `Menu Separator` **is visible,** then the status icons inside the **Hide Area** are **visible.** Otherwise the icons are **hidden.**
60 | - When using other themes, all the separators perform together. If all of them are **translucent,** then the status icons inside the **Hide Area** are **visible.** Otherwise the icons are **hidden.**
61 |
62 | [^cursor_onto_status_bar]: You need to move your cursor further away from the screen corner than the `Menu Separator` in order to trigger something. On monitors with notches, you may also need to move your cursor _between the the screen notch and the `Menu Separator`._
63 |
64 | Dragging the icons while holding ⌘ command can reorder the status icons and the separators. For example, to put more icons into or out of the **Hide Area.**
65 |
66 |
67 |
68 | ### Clicking on the Separators
69 |
70 | You can perform different actions by clicking on the separators of **Abyssal,** no matter whether they are visible:
71 |
72 |
73 |
74 | #### The Always Hide Separator
75 |
76 | - **click / right click**
77 |
78 | **Show / hide** the status icons inside the **Hide Area.**
79 |
80 |
81 |
82 | #### The Hide Separator
83 |
84 | - **click / right click**
85 |
86 | **Show / hide** the status icons inside the **Hide Area.**
87 |
88 |
89 |
90 | #### The Menu Separator
91 |
92 | - **click**
93 |
94 | **Show / hide** the status icons inside the **Hide Area.**
95 |
96 | - **⌥ option click**
97 |
98 | **Open / close** the preferences menu.
99 |
100 |
101 |
102 | ### What's More: Auto Idling
103 |
104 | Due to the limitations of macOS, **Abyssal** cannot know whether you have opened a menu in the **Always Hide Area** or the **Hide Area.** If the **Auto Hide** function hides these status icons rashly after your cursor leave the menu bar, their menus will also move away. Therefore, **Abyssal** adopts an approach to avoid similar situations to the greatest extent.
105 |
106 | Speaking specifically, when you click on a place in the menu bar **where there is likely to have other status icons, and the status icon is likely to be inside the Hide Area or the Always Hide Area,** **Abyssal** will choose to pause the **Auto Hide** and enter the **Auto Idling** state. When you finish the operation, just move the cursor **over** the `Always Hide Separator` or the `Hide Separator`, and you can cancel the **Auto Idling** state and resume **Auto Hide** to hide the status icons. **Abyssal** also provides an optional timeout to automatically disable the **Auto Idling** state, which can be configured in the preferences menu.
107 |
108 | **Auto Idling** will enable automatically accordng to your clicking position, and it will distinguish between the **Always Hide Area** and the **Hide Area** - different areas trigger different reactions. It will only be activated when **Auto Hide** is enabled.
109 |
110 | After you **triggered or canceled** **Auto Hide or Auto Idling,** **Abyssal** will generate a haptic feedback[^haptic_feedback_support_needed].
111 |
112 | [^haptic_feedback_support_needed]: Your device must support _haptic feedback._
113 |
114 |
115 |
116 | ## Install & Run
117 |
118 | > [!NOTE]
119 | >
120 | > As an open source and free software, **Abyssal** can't afford an [Apple Developer Account.](https://developer.apple.com/help/account) Therefore, you can't install **Abyssal** directly from App Store, and you may need to allow **Abyssal** to run as an unidentified app[^open_as_unidentified].
121 | >
122 | > You can download the zipped app of **Abyssal** only from [Releases](https://github.com?KrLite/Abyssal/releases) page manually for now.
123 |
124 | [^open_as_unidentified]: [`↗ Open a Mac app from an unidentified developer`](https://support.apple.com/guide/mac-help/mh40616/mac)
125 |
--------------------------------------------------------------------------------
/Abyssal/Models/VersionModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VersionModel.swift
3 | // Abyssal
4 | //
5 | // Created by KrLite on 2024/6/24.
6 | //
7 |
8 | import Foundation
9 |
10 | struct Version: Codable {
11 | enum Component: Codable {
12 | case number(UInt)
13 | case beta
14 | case alpha
15 | case patch
16 | case blank
17 |
18 | var nonNumeric: Bool {
19 | switch self {
20 | case .number(_), .blank: false
21 | case .beta, .alpha, .patch: true
22 | }
23 | }
24 |
25 | var semantic: String {
26 | switch self {
27 | case .number(let uInt):
28 | String(uInt)
29 | case .beta:
30 | "beta"
31 | case .alpha:
32 | "alpha"
33 | case .patch:
34 | "patch"
35 | case .blank:
36 | ""
37 | }
38 | }
39 |
40 | var separator: String {
41 | nonNumeric ? "-" : "."
42 | }
43 | }
44 |
45 | var components: [Component]
46 |
47 | var string: String {
48 | components
49 | .reduce("") { result, component in
50 | if result.isEmpty {
51 | component.semantic
52 | } else {
53 | result + component.separator + component.semantic
54 | }
55 | }
56 | }
57 |
58 | static var empty: Version = .init(components: [])
59 | static var app: Version {
60 | .init(from: Bundle.main.appVersion) ?? .empty
61 | }
62 | static var remote: Version {
63 | VersionModel.shared.fetchedRemoteVersion
64 | }
65 |
66 | static var hasUpdate: Bool {
67 | app < remote
68 | }
69 |
70 | init(components: [Component]) {
71 | self.components = components
72 | }
73 |
74 | init?(from: String) {
75 | let parts = from
76 | .replacing(/\s/, with: "")
77 | .split(separator: /[\.-]/) // Split by `.` or `-`
78 | let components = parts.compactMap({ Component(parsing: String($0)) })
79 |
80 | if components.isEmpty {
81 | return nil
82 | } else {
83 | self.components = components
84 | }
85 | }
86 | }
87 |
88 | extension Version.Component: Comparable {
89 | static func <(lhs: Self, rhs: Self) -> Bool {
90 | guard lhs != rhs else { return false }
91 |
92 | return switch lhs {
93 | case .number(let this):
94 | switch rhs {
95 | case .number(let other):
96 | this < other
97 | case .beta, .alpha, .patch, .blank: false
98 | }
99 | case .beta:
100 | switch rhs {
101 | case .number(_), .blank: true
102 | case .beta, .alpha, .patch: false
103 | }
104 | case .alpha:
105 | switch rhs {
106 | case .number(_), .beta, .blank: true
107 | case .alpha, .patch: false
108 | }
109 | case .patch:
110 | switch rhs {
111 | case .number(_), .beta, .alpha, .patch, .blank: false
112 | }
113 | case .blank:
114 | switch rhs {
115 | case .number(_): true
116 | case .beta, .alpha, .patch, .blank: false
117 | }
118 | }
119 | }
120 | }
121 |
122 | extension Version.Component {
123 | init?(parsing: String) {
124 | for nonNumeric in Self.nonNumerics {
125 | if parsing.lowercased() == nonNumeric.semantic.lowercased() {
126 | self = nonNumeric
127 | return
128 | }
129 | }
130 |
131 | if let number = UInt(parsing) {
132 | self = .number(number)
133 | return
134 | }
135 |
136 | return nil
137 | }
138 |
139 | static var iterables: [Self] {
140 | [.beta, .alpha, .patch, .blank]
141 | }
142 |
143 | static var nonNumerics: [Self] {
144 | iterables.filter(\.nonNumeric)
145 | }
146 | }
147 |
148 | extension Version: Comparable {
149 | static func <(lhs: Self, rhs: Self) -> Bool {
150 | guard lhs != rhs else { return false }
151 |
152 | let count = max(lhs.components.count, rhs.components.count)
153 | for index in 0.. rhsComponent {
160 | return false
161 | }
162 |
163 | // Two components are equal, continue
164 | }
165 |
166 | return false
167 | }
168 | }
169 |
170 | @Observable
171 | class VersionModel {
172 | static var shared = VersionModel()
173 |
174 | enum FetchState {
175 | case initialized // Before first fetch
176 | case fetching
177 | case finished // Fetch succeed
178 | case failed // Fetch failed
179 |
180 | var idle: Bool {
181 | switch self {
182 | case .fetching:
183 | false
184 | case .initialized, .finished, .failed:
185 | true
186 | }
187 | }
188 | }
189 |
190 | fileprivate var fetchedRemoteVersion: Version = .app
191 |
192 | private var task: URLSessionTask?
193 |
194 | var fetchState: FetchState = .initialized
195 |
196 | var empty: Version {
197 | .empty
198 | }
199 |
200 | var app: Version {
201 | .app
202 | }
203 |
204 | var remote: Version {
205 | fetchedRemoteVersion
206 | }
207 |
208 | var hasUpdate: Bool {
209 | Version.hasUpdate
210 | }
211 |
212 | func fetchLatest() {
213 | task?.cancel()
214 |
215 | print("Started fetching latest version...")
216 | fetchState = .fetching
217 |
218 | task = URLSession.shared.dataTask(with: .releaseTags) { (data, response, error) in
219 | guard let data else {
220 | self.fetchState = .failed
221 | return
222 | }
223 |
224 | do {
225 | if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] {
226 | let tags = json
227 | .compactMap { element in
228 | element["name"] as? String
229 | }
230 | .compactMap { Version(from: $0) }
231 | .sorted(by: >)
232 |
233 | if let remote = tags.first, remote > .app {
234 | self.fetchedRemoteVersion = remote
235 | print("Fetched latest version: \(remote.string)")
236 | print(self.app < self.remote)
237 | } else {
238 | print("No newer version available.")
239 | }
240 |
241 | self.fetchState = .finished
242 | }
243 | } catch {
244 | self.fetchState = .failed
245 | print(error.localizedDescription)
246 | }
247 | }
248 | task?.resume()
249 | }
250 | }
251 |
--------------------------------------------------------------------------------
/Abyssal/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Abyssal
4 | //
5 | // Created by KrLite on 2023/6/13.
6 | //
7 |
8 | import Cocoa
9 | import SwiftUI
10 | import AppKit
11 | import Defaults
12 | import LaunchAtLogin
13 |
14 | let repository = "Cement-Labs/Abyssal"
15 |
16 | //@main
17 | class AppDelegate: NSObject, NSApplicationDelegate {
18 | static var shared: AppDelegate? {
19 | NSApplication.shared.delegate as? AppDelegate
20 | }
21 |
22 | let popover: NSPopover = NSPopover()
23 |
24 | let statusBarController = StatusBarController()
25 |
26 | // MARK: - Event Monitors
27 |
28 | var mouseEventMonitor: EventMonitor?
29 |
30 | // MARK: - Application Methods
31 |
32 | func applicationDidFinishLaunching(
33 | _ aNotification: Notification
34 | ) {
35 | // Set activation policy to `prohibited` after launched
36 | ActivationPolicyManager.set(.prohibited, asFallback: true)
37 |
38 | // Initialize view controller
39 | let controller = SettingsViewController()
40 | controller.view = NSHostingView(rootView: SettingsView())
41 | popover.contentViewController = controller
42 |
43 | // Pre-initialize view frame
44 | controller.initializeFrame()
45 |
46 | // Fetch latest version
47 | VersionModel.shared.fetchLatest()
48 |
49 | popover.behavior = .applicationDefined
50 | popover.delegate = self
51 |
52 | mouseEventMonitor = EventMonitor(
53 | mask: [.leftMouseDown,
54 | .rightMouseDown]
55 | ) { [weak self] event in
56 | if let strongSelf = self {
57 | if strongSelf.popover.isShown {
58 | // Close popover when clicked outside
59 | strongSelf.closePopover(event)
60 | }
61 | }
62 | }
63 | }
64 |
65 | func applicationWillTerminate(
66 | _ aNotification: Notification
67 | ) {
68 | }
69 |
70 | func applicationSupportsSecureRestorableState(
71 | _ app: NSApplication
72 | ) -> Bool {
73 | true
74 | }
75 | }
76 |
77 | extension AppDelegate: NSPopoverDelegate {
78 | func popoverShouldDetach(_ popover: NSPopover) -> Bool {
79 | true
80 | }
81 | }
82 |
83 | extension AppDelegate {
84 | @objc func quit(
85 | _ sender: Any?
86 | ) {
87 | NSApplication.shared.terminate(sender)
88 | }
89 |
90 | @objc func escapeFromOverridingMenuBar(
91 | _ sender: Any?
92 | ) {
93 | if popover.isShown {
94 | closePopover(sender)
95 | } else {
96 | ActivationPolicyManager.set(.prohibited, asFallback: true)
97 | statusBarController.unidleHideArea()
98 | }
99 | }
100 |
101 | // MARK: - Toggles
102 |
103 | @objc func toggle(
104 | _ sender: Any?
105 | ) {
106 | guard sender as? NSStatusBarButton == AppDelegate.shared?.statusBarController.head.button else {
107 | toggleActive(sender)
108 | return
109 | }
110 |
111 | if KeyboardModel.shared.option {
112 | togglePopover(sender)
113 | } else {
114 | if let event = NSApp.currentEvent, event.type == .rightMouseUp {
115 | togglePopover(sender)
116 | } else {
117 | toggleActive(sender)
118 | }
119 | }
120 | }
121 |
122 | @objc func toggleActive(
123 | _ sender: Any?
124 | ) {
125 | statusBarController.function()
126 |
127 | guard !(statusBarController.idling.hide || statusBarController.idling.alwaysHide) else {
128 | statusBarController.unidleHideArea()
129 | return
130 | }
131 |
132 | if Defaults[.isActive] {
133 | statusBarController.deactivate()
134 | } else {
135 | statusBarController.activate()
136 | }
137 | }
138 |
139 | @objc func togglePopover(
140 | _ sender: Any?
141 | ) {
142 | if popover.isShown {
143 | closePopover(sender)
144 | } else {
145 | showPopover(sender)
146 | }
147 | }
148 |
149 | func showPopover(
150 | _ sender: Any?
151 | ) {
152 | if let controller = popover.contentViewController {
153 | if let button = statusBarController.head.button ?? sender as? NSButton {
154 | // Position popover
155 |
156 | let buttonRect = button.convert(button.bounds, to: nil)
157 | let screenRect = button.window!.convertToScreen(buttonRect)
158 |
159 | let invisiblePanel = NSPanel(
160 | contentRect: NSMakeRect(0, 0, 1, 5),
161 | styleMask: [.borderless],
162 | backing: .buffered,
163 | defer: false,
164 | screen: .main
165 | )
166 | invisiblePanel.isFloatingPanel = true
167 | invisiblePanel.alphaValue = 0
168 |
169 | invisiblePanel.setFrameOrigin(NSPoint(
170 | x: screenRect.maxX,
171 | y: screenRect.maxY
172 | ))
173 | invisiblePanel.makeKeyAndOrderFront(nil)
174 |
175 | popover.show(
176 | relativeTo: invisiblePanel.contentView!.frame,
177 | of: invisiblePanel.contentView!,
178 | preferredEdge: .maxY
179 | )
180 |
181 | // Set to foreground activation policy
182 |
183 | let overridesMenuBar = Defaults[.autoOverridesMenuBarEnabled]
184 | let activationPolicy: NSApplication.ActivationPolicy = overridesMenuBar ? .regular : .accessory
185 |
186 | Defaults[.menuBarOverride].apply()
187 | ActivationPolicyManager.set(activationPolicy, asFallback: true)
188 | NSApp.activate()
189 |
190 | DispatchQueue.main.async(popover) {
191 | controller.viewWillAppear()
192 | controller.view.window?.makeKeyAndOrderFront(nil)
193 | controller.viewDidAppear()
194 | }
195 | }
196 |
197 | //mouseEventMonitor?.start()
198 | }
199 | }
200 |
201 | func closePopover(
202 | _ sender: Any?
203 | ) {
204 | if let controller = popover.contentViewController {
205 | DispatchQueue.main.async(popover) {
206 | controller.viewWillDisappear()
207 | self.popover.close() // Force it to close, thus closing all nested popovers
208 | controller.viewDidDisappear()
209 | }
210 |
211 | // Restore activation policy
212 |
213 | // 1. Set to `accessory` after closed to prevent the popover from not being able to open properly again
214 | ActivationPolicyManager.set(.accessory, asFallback: true, deadline: .now() + 0.2) {
215 | // 2. Set to `prohibited` asynchronously to order out
216 | ActivationPolicyManager.set(.prohibited, asFallback: true, deadline: .now())
217 | }
218 |
219 | // Stop functioning
220 |
221 | mouseEventMonitor?.stop()
222 | statusBarController.function()
223 | statusBarController.triggerIgnoring()
224 | }
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/Abyssal/Menu Bar/StatusBarController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StatusBarController.swift
3 | // Abyssal
4 | //
5 | // Created by KrLite on 2023/6/13.
6 | //
7 |
8 | import AppKit
9 | import Defaults
10 |
11 | class StatusBarController {
12 | // MARK: - Lazy States
13 |
14 | lazy var mouseOnStatusBar: WithIntermediateState = .init {
15 | guard
16 | let headOrigin = self.head.button?.window?.frame.origin,
17 | let headSize = self.head.button?.window?.frame.size
18 | else { return false }
19 | let mouseLocation = NSEvent.mouseLocation
20 | return mouseLocation.x >= ScreenManager.menuBarLeftEdge && mouseLocation.y >= headOrigin.y && mouseLocation.y <= headOrigin.y + headSize.height
21 | }
22 |
23 | lazy var mouseInHideArea: WithIntermediateState = .init {
24 | guard
25 | let bodyOrigin = self.body.button?.window?.frame.origin,
26 | let tailOrigin = self.tail.button?.window?.frame.origin,
27 | let tailSize = self.tail.button?.window?.frame.size
28 | else { return false }
29 | return self.mouseOnStatusBar.value() && NSEvent.mouseLocation.x >= tailOrigin.x + tailSize.width && NSEvent.mouseLocation.x <= bodyOrigin.x
30 | }
31 |
32 | lazy var mouseInAlwaysHideArea: WithIntermediateState = .init {
33 | guard let origin = self.tail.button?.window?.frame.origin else { return false }
34 | return self.mouseOnStatusBar.value() && NSEvent.mouseLocation.x <= origin.x
35 | }
36 |
37 | lazy var mouseSpare: WithIntermediateState = .init {
38 | !self.ignoring && self.mouseOnStatusBar.value() && NSEvent.mouseLocation.x <= self.edge
39 | }
40 |
41 |
42 |
43 | lazy var mouseOverHead: WithIntermediateState = .init {
44 | guard
45 | let origin = self.head.button?.window?.frame.origin,
46 | let width = self.head.button?.window?.frame.width
47 | else { return false }
48 | return self.mouseOnStatusBar.value() && NSEvent.mouseLocation.x >= origin.x && NSEvent.mouseLocation.x <= origin.x + width
49 | }
50 |
51 | lazy var mouseOverBody: WithIntermediateState = .init {
52 | guard
53 | let origin = self.body.button?.window?.frame.origin,
54 | let width = self.body.button?.window?.frame.width
55 | else { return false }
56 | return self.mouseOnStatusBar.value() && NSEvent.mouseLocation.x >= origin.x && NSEvent.mouseLocation.x <= origin.x + width
57 | }
58 |
59 | lazy var mouseOverTail: WithIntermediateState = .init {
60 | guard
61 | let origin = self.tail.button?.window?.frame.origin,
62 | let width = self.tail.button?.window?.frame.width
63 | else { return false }
64 | return self.mouseOnStatusBar.value() && NSEvent.mouseLocation.x >= origin.x && NSEvent.mouseLocation.x <= origin.x + width
65 | }
66 |
67 | lazy var mouseDragging: WithIntermediateState = .init {
68 | MouseModel.shared.dragging && self.mouseOnStatusBar.value()
69 | }
70 |
71 |
72 |
73 | lazy var hasExternalMenus: WithIntermediateState = .init {
74 | !self.externalMenus.isEmpty
75 | }
76 |
77 | lazy var keyboardTriggers: WithIntermediateState = .init {
78 | KeyboardModel.shared.triggers
79 | }
80 |
81 | lazy var focusedApp: WithIntermediateState = .init {
82 | AppManager.frontmost
83 | }
84 |
85 | lazy var mainScreen: WithIntermediateState = .init {
86 | ScreenManager.main
87 | }
88 |
89 |
90 |
91 | lazy var blocking: WithIntermediateState = .init {
92 | self.hasExternalMenus.value()
93 | }
94 |
95 |
96 |
97 | // MARK: - Variable States
98 |
99 | var shouldPresentFeedback: Bool {
100 | !timeout && MouseModel.shared.none
101 | }
102 |
103 | var maxLength: CGFloat {
104 | ScreenManager.maxWidth
105 | }
106 |
107 | var popoverShown: Bool {
108 | AppDelegate.shared?.popover.isShown ?? false
109 | }
110 |
111 |
112 |
113 | var edge = CGFloat.zero
114 |
115 | var shouldEdgeUpdate = (now: false, will: false)
116 |
117 | var idling = (hide: false, alwaysHide: false)
118 |
119 | var noAnimation = false
120 |
121 | var ignoring = false
122 |
123 | var timeout = false
124 |
125 | var externalMenus: [WindowInfo] = []
126 |
127 |
128 |
129 | var feedbackCount = Int.zero
130 |
131 | var shouldTimersStop = (flag: false, count: Int.zero)
132 |
133 | var mouseWasSpareOrUnidled = false
134 |
135 | var draggedToDeactivate = (dragging: false, count: Int.zero)
136 |
137 |
138 |
139 | // MARK: - Timers & Event Monitors
140 |
141 | var animationTimer: Timer?
142 |
143 | var actionTimer: Timer?
144 |
145 | var feedbackTimer: Timer?
146 |
147 | var triggerTimer: Timer?
148 |
149 | var timeoutTimer: Timer?
150 |
151 | var ignoringTimer: Timer?
152 |
153 |
154 |
155 | var mouseEventMonitor: EventMonitor?
156 |
157 |
158 |
159 | // MARK: - Icons
160 |
161 | // Status items
162 |
163 | private static let _item0 = NSStatusBar.system.statusItem(
164 | withLength: NSStatusItem.variableLength
165 | )
166 |
167 | private static let _item1 = NSStatusBar.system.statusItem(
168 | withLength: NSStatusItem.variableLength
169 | )
170 |
171 | private static let _item2 = NSStatusBar.system.statusItem(
172 | withLength: NSStatusItem.variableLength
173 | )
174 |
175 | private static var _items = [_item0, _item1, _item2]
176 |
177 | // Separators
178 |
179 | var head = Separator(order: 2) { StatusBarController._items }
180 |
181 | var body = Separator(order: 1) { StatusBarController._items }
182 |
183 | var tail = Separator(order: 0) { StatusBarController._items }
184 |
185 | // MARK: - Inits
186 |
187 | init() {
188 | // Init separators
189 |
190 | // By default, _item0 is the most left while _item2 is the most right.
191 | // However this will change to conserve the relative position of the separators.
192 | sort()
193 |
194 | if let button = StatusBarController._item0.button {
195 | button.action = #selector(AppDelegate.toggle(_:))
196 | button.sendAction(on: [.leftMouseUp, .rightMouseUp])
197 | }
198 |
199 | if let button = StatusBarController._item1.button {
200 | button.action = #selector(AppDelegate.toggle(_:))
201 | button.sendAction(on: [.leftMouseUp, .rightMouseUp])
202 | }
203 |
204 | if let button = StatusBarController._item2.button {
205 | button.action = #selector(AppDelegate.toggle(_:))
206 | button.sendAction(on: [.leftMouseUp, .rightMouseUp])
207 | }
208 |
209 | // Start services
210 |
211 | startAnimationTimer()
212 | startActionTimer()
213 |
214 | startTriggerTimer()
215 |
216 | registerShortcuts()
217 | }
218 |
219 | deinit {
220 | // Stop services
221 |
222 | stopTimer(&animationTimer)
223 | stopTimer(&actionTimer)
224 |
225 | stopTimer(&triggerTimer)
226 |
227 | stopTimer(&timeoutTimer)
228 | stopTimer(&ignoringTimer)
229 |
230 | stopMonitor(&mouseEventMonitor)
231 | }
232 | }
233 |
234 | extension StatusBarController {
235 | func sort() {
236 | // Make sure the rightmost separator is positioned further back in the array
237 | StatusBarController._items.sort { (first, second) in
238 | if !first.isVisible {
239 | // The first one is invisible -> the first one is more lefty
240 | return true
241 | } else if !second.isVisible {
242 | // The first one is visible while the second one is invisible -> the second one is more lefty
243 | return false
244 | } else if let x1 = first.origin?.x, let x2 = second.origin?.x {
245 | // Both have reasonable x positions -> the leftmost one is more lefty
246 | return x1 <= x2
247 | } else { return true }
248 | }
249 | }
250 |
251 | func updateEdge() {
252 | edge = (body.origin?.x ?? 0) + body.length
253 | }
254 |
255 | func updateExternalMenus() {
256 | externalMenus = ExternalMenuBarManager.menuBarItems.flatMap {
257 | $0.newWindowsNear
258 | }
259 | }
260 | }
261 |
--------------------------------------------------------------------------------
/Abyssal/Extensions/Defaults+Structures.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Defaults+Structures.swift
3 | // Abyssal
4 | //
5 | // Created by KrLite on 2024/2/8.
6 | //
7 |
8 | import Foundation
9 | import Defaults
10 | import AppKit
11 | import SwiftUI
12 |
13 | extension Theme: Defaults.Serializable {
14 | struct Bridge: Defaults.Bridge {
15 | typealias Value = Theme
16 | typealias Serializable = String
17 |
18 | func serialize(_ value: Theme?) -> String? {
19 | guard let value else {
20 | return nil
21 | }
22 |
23 | return value.id
24 | }
25 |
26 | func deserialize(_ object: String?) -> Theme? {
27 | guard
28 | let id = object,
29 | Theme.themeIds.contains(id)
30 | else {
31 | return nil
32 | }
33 |
34 | return Theme.themes.first { $0.id == id }
35 | }
36 | }
37 |
38 | static let bridge = Bridge()
39 | }
40 |
41 | struct Modifier: OptionSet, Defaults.Serializable {
42 | let rawValue: UInt8
43 |
44 | static let control = Modifier(rawValue: 1 << 0)
45 | static let option = Modifier(rawValue: 1 << 1)
46 | static let command = Modifier(rawValue: 1 << 2)
47 |
48 | static let none: Modifier = []
49 | static let all: Modifier = [.control, .option, .command]
50 |
51 | var control: Bool {
52 | get {
53 | self.contains(.control)
54 | }
55 |
56 | set {
57 | if newValue {
58 | self.formUnion(.control)
59 | } else {
60 | self.remove(.control)
61 | }
62 | }
63 | }
64 |
65 | var option: Bool {
66 | get {
67 | self.contains(.option)
68 | }
69 |
70 | set {
71 | if newValue {
72 | self.formUnion(.option)
73 | } else {
74 | self.remove(.option)
75 | }
76 | }
77 | }
78 |
79 | var command: Bool {
80 | get {
81 | self.contains(.command)
82 | }
83 |
84 | set {
85 | if newValue {
86 | self.formUnion(.command)
87 | } else {
88 | self.remove(.command)
89 | }
90 | }
91 | }
92 |
93 | var flags: NSEvent.ModifierFlags {
94 | var result = NSEvent.ModifierFlags()
95 |
96 | if self.contains(.control) {
97 | result.formUnion(.control)
98 | }
99 | if self.contains(.option) {
100 | result.formUnion(.option)
101 | }
102 | if self.contains(.command) {
103 | result.formUnion(.command)
104 | }
105 |
106 | return result
107 | }
108 |
109 | static func fromFlags(_ flags: NSEvent.ModifierFlags) -> Modifier {
110 | var result = Modifier()
111 |
112 | if flags.contains(.control) {
113 | result.formUnion(.control)
114 | }
115 | if flags.contains(.option) {
116 | result.formUnion(.option)
117 | }
118 | if flags.contains(.command) {
119 | result.formUnion(.command)
120 | }
121 |
122 | return result
123 | }
124 | }
125 |
126 | extension Modifier {
127 | enum Compose: String, CaseIterable, Codable, Defaults.Serializable {
128 | case any = "any"
129 | case all = "all"
130 |
131 | func triggers(input: Modifier) -> Bool {
132 | switch self {
133 | case .any:
134 | // OK if the two sets have any member in common
135 | !Defaults[.modifier].isDisjoint(with: input)
136 | case .all:
137 | // OK if the input is a superset of the configured
138 | input.isSuperset(of: Defaults[.modifier])
139 | }
140 | }
141 | }
142 | }
143 |
144 | enum Timeout: Int, CaseIterable, Defaults.Serializable {
145 | case instant = 0
146 |
147 | case sec5 = 5
148 | case sec10 = 10
149 | case sec15 = 15
150 | case sec30 = 30
151 | case sec45 = 45
152 | case sec60 = 60
153 |
154 | case min2 = 120
155 | case min3 = 180
156 | case min5 = 300
157 | case min10 = 600
158 |
159 | case forever = -1
160 |
161 | var attribute: Int? {
162 | switch self {
163 | case .forever: nil
164 | default: self.rawValue
165 | }
166 | }
167 | }
168 |
169 | enum Feedback: Int, CaseIterable, Defaults.Serializable {
170 | case none = 0
171 | case light = 1
172 | case medium = 2
173 | case heavy = 3
174 |
175 | var pattern: [NSHapticFeedbackManager.FeedbackPattern?] {
176 | switch self {
177 | case .light: [.levelChange]
178 | case .medium: [.generic, nil, .alignment]
179 | case .heavy: [.levelChange, .alignment, .alignment, nil, nil, nil, .levelChange]
180 |
181 | default: []
182 | }
183 | }
184 | }
185 |
186 | enum DeadZone: Codable, Defaults.Serializable {
187 | case percentage(Double)
188 | case pixel(Double)
189 |
190 | var range: ClosedRange {
191 | mode.range
192 | }
193 |
194 | var value: Double {
195 | get {
196 | switch self {
197 | case .percentage(let percentage):
198 | percentage
199 | case .pixel(let pixel):
200 | pixel
201 | }
202 | }
203 |
204 | set {
205 | self = mode.wrap(newValue)
206 | }
207 | }
208 |
209 | var sliderPercentage: Double {
210 | get {
211 | range.percentage(value)
212 | }
213 |
214 | set(percentage) {
215 | value = range.fromPercentage(percentage)
216 | }
217 | }
218 |
219 | var screenPixel: Double {
220 | switch self {
221 | case .percentage(_):
222 | Mode.pixel.range.percentage(sliderPercentage)
223 | case .pixel(let pixel):
224 | pixel
225 | }
226 | }
227 | }
228 |
229 | extension DeadZone {
230 | enum Mode: CaseIterable {
231 | case percentage
232 | case pixel
233 |
234 | var range: ClosedRange {
235 | switch self {
236 | case .percentage:
237 | 0...75
238 | case .pixel:
239 | 0...ScreenManager.width
240 | }
241 | }
242 |
243 | func wrap(_ value: Double) -> DeadZone {
244 | switch self {
245 | case .percentage:
246 | .percentage(value)
247 | case .pixel:
248 | .pixel(value)
249 | }
250 | }
251 |
252 | func from(_ deadZone: DeadZone) -> Double {
253 | guard self != deadZone.mode else {
254 | return deadZone.value
255 | }
256 |
257 | return switch self {
258 | case .percentage:
259 | switch deadZone {
260 | case .pixel(_):
261 | deadZone.sliderPercentage * 100
262 | default: deadZone.value
263 | }
264 | case .pixel:
265 | switch deadZone {
266 | case .percentage(let percentage):
267 | range.fromPercentage(percentage / 100)
268 | default: deadZone.value
269 | }
270 | }
271 | }
272 | }
273 |
274 | var mode: Mode {
275 | get {
276 | switch self {
277 | case .percentage(_):
278 | .percentage
279 | case .pixel(_):
280 | .pixel
281 | }
282 | }
283 |
284 | set(type) {
285 | guard type != self.mode else { return }
286 |
287 | self = type.wrap(type.from(self))
288 | }
289 | }
290 | }
291 |
292 | extension DeadZone: Equatable {
293 |
294 | }
295 |
296 | struct ActiveStrategy: Codable, Defaults.Serializable {
297 | /// When frontmost app changes
298 | var frontmostAppChange: Bool
299 | /// When cursor interaction invalidates in menus
300 | var interactionInvalidate: Bool
301 | /// When current screen changes
302 | var screenChange: Bool
303 |
304 | var values: [Bool] {
305 | [
306 | frontmostAppChange,
307 | interactionInvalidate,
308 | screenChange
309 | ]
310 | }
311 |
312 | var enabledCount: Int {
313 | values.count { $0 }
314 | }
315 | }
316 |
317 | struct ScreenSettings: Codable, Defaults.Serializable {
318 | struct Individual: Codable, Defaults.Serializable {
319 | var activeStrategy: ActiveStrategy
320 | var deadZone: DeadZone
321 |
322 | var respectNotch: Bool
323 | }
324 |
325 | var global: Individual
326 | var unique: [CGDirectDisplayID: Individual]
327 |
328 | var main: Individual {
329 | get {
330 | guard let id = ScreenManager.main?.displayID else {
331 | return global
332 | }
333 |
334 | return unique[id] ?? global
335 | }
336 |
337 | set(individual) {
338 | guard
339 | let id = ScreenManager.main?.displayID,
340 | isMainUnique
341 | else {
342 | global = individual
343 | return
344 | }
345 |
346 | unique[id] = individual
347 | }
348 | }
349 |
350 | var isMainUnique: Bool {
351 | get {
352 | guard let id = ScreenManager.main?.displayID else {
353 | return false
354 | }
355 |
356 | return unique.keys.contains { $0 == id }
357 | }
358 |
359 | set(isUnique) {
360 | guard let id = ScreenManager.main?.displayID else {
361 | return
362 | }
363 |
364 | if isUnique {
365 | let encoder = JSONEncoder()
366 | let data = try? encoder.encode(global)
367 | if
368 | let data,
369 | let copied = try? JSONDecoder().decode(Individual.self, from: data)
370 | {
371 | unique[id] = copied
372 | }
373 | } else {
374 | unique.removeValue(forKey: id)
375 | }
376 | }
377 | }
378 | }
379 |
380 | enum MenuBarOverride: CaseIterable, Codable, Defaults.Serializable {
381 | case app
382 | case empty
383 |
384 | var menu: NSMenu? {
385 | switch self {
386 | case .app:
387 | appMenu
388 | case .empty:
389 | emptyMenu
390 | }
391 | }
392 |
393 | func apply() {
394 | ApplicationMenuManager.apply(menu)
395 | }
396 | }
397 |
--------------------------------------------------------------------------------