Add SimpleTopBar menu bar app with collapsible background app list
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
|
||||
/// A snapshot of one running application shown in the top bar.
|
||||
struct RunningApp: Identifiable, Equatable {
|
||||
let id: pid_t
|
||||
let name: String
|
||||
let bundleID: String?
|
||||
let icon: NSImage
|
||||
let isHidden: Bool
|
||||
let isActive: Bool
|
||||
|
||||
static func == (lhs: RunningApp, rhs: RunningApp) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
&& lhs.name == rhs.name
|
||||
&& lhs.isHidden == rhs.isHidden
|
||||
&& lhs.isActive == rhs.isActive
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks running applications and splits them into the two top bar sections:
|
||||
/// regular apps (with a Dock icon) and background apps (accessory/agent apps).
|
||||
@MainActor
|
||||
final class AppListModel: ObservableObject {
|
||||
static let shared = AppListModel()
|
||||
|
||||
/// Number of background apps shown before the rest are hidden behind the
|
||||
/// "Show more" affordance.
|
||||
nonisolated static let backgroundPageSize = 5
|
||||
|
||||
@Published private(set) var regularApps: [RunningApp] = []
|
||||
@Published private(set) var backgroundApps: [RunningApp] = []
|
||||
|
||||
/// Whether the background-app section is expanded. Collapsed means the
|
||||
/// section renders as a single dropdown header.
|
||||
@Published var backgroundExpanded = false
|
||||
/// How many background apps are currently revealed after expanding.
|
||||
@Published private(set) var backgroundVisibleCount = AppListModel.backgroundPageSize
|
||||
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
refresh()
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
let names: [Notification.Name] = [
|
||||
NSWorkspace.didLaunchApplicationNotification,
|
||||
NSWorkspace.didTerminateApplicationNotification,
|
||||
NSWorkspace.didActivateApplicationNotification,
|
||||
NSWorkspace.didHideApplicationNotification,
|
||||
NSWorkspace.didUnhideApplicationNotification,
|
||||
]
|
||||
for name in names {
|
||||
observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
Task { @MainActor in self?.refresh() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
let ownBundleID = Bundle.main.bundleIdentifier
|
||||
var regular: [RunningApp] = []
|
||||
var background: [RunningApp] = []
|
||||
|
||||
for app in NSWorkspace.shared.runningApplications {
|
||||
// Never list ourselves.
|
||||
if let ownBundleID, app.bundleIdentifier == ownBundleID { continue }
|
||||
|
||||
let info = RunningApp(
|
||||
id: app.processIdentifier,
|
||||
name: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||
bundleID: app.bundleIdentifier,
|
||||
icon: app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage(),
|
||||
isHidden: app.isHidden,
|
||||
isActive: app.isActive
|
||||
)
|
||||
|
||||
if app.activationPolicy == .regular {
|
||||
regular.append(info)
|
||||
} else {
|
||||
background.append(info)
|
||||
}
|
||||
}
|
||||
|
||||
regularApps = Self.sorted(regular)
|
||||
backgroundApps = Self.sorted(background)
|
||||
|
||||
// Keep the revealed count valid as apps come and go.
|
||||
backgroundVisibleCount = Self.clampedVisibleCount(
|
||||
current: backgroundVisibleCount,
|
||||
total: backgroundApps.count
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated static func sorted(_ apps: [RunningApp]) -> [RunningApp] {
|
||||
apps.sorted { a, b in
|
||||
let result = a.name.localizedCaseInsensitiveCompare(b.name)
|
||||
if result == .orderedSame { return a.id < b.id }
|
||||
return result == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Background apps that the expanded section should render right now.
|
||||
var visibleBackgroundApps: [RunningApp] {
|
||||
guard backgroundExpanded else { return [] }
|
||||
return Array(backgroundApps.prefix(backgroundVisibleCount))
|
||||
}
|
||||
|
||||
/// How many background apps are currently hidden behind "Show more".
|
||||
var hiddenBackgroundCount: Int {
|
||||
max(0, backgroundApps.count - backgroundVisibleCount)
|
||||
}
|
||||
|
||||
var canShowMoreBackgroundApps: Bool { hiddenBackgroundCount > 0 }
|
||||
|
||||
func toggleBackgroundExpanded() {
|
||||
backgroundExpanded.toggle()
|
||||
if !backgroundExpanded {
|
||||
backgroundVisibleCount = Self.backgroundPageSize
|
||||
}
|
||||
}
|
||||
|
||||
func showMoreBackgroundApps() {
|
||||
backgroundVisibleCount = Self.nextVisibleCount(
|
||||
current: backgroundVisibleCount,
|
||||
total: backgroundApps.count
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Pure helpers (exercised by --self-test)
|
||||
|
||||
nonisolated static func clampedVisibleCount(current: Int, total: Int) -> Int {
|
||||
max(0, min(current, total))
|
||||
}
|
||||
|
||||
nonisolated static func nextVisibleCount(current: Int, total: Int) -> Int {
|
||||
min(total, current + backgroundPageSize)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func activate(_ app: RunningApp) -> Bool {
|
||||
guard let running = NSRunningApplication(processIdentifier: app.id) else { return false }
|
||||
return running.activate(options: [.activateIgnoringOtherApps])
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleHidden(_ app: RunningApp) -> Bool {
|
||||
guard let running = NSRunningApplication(processIdentifier: app.id) else { return false }
|
||||
if running.isHidden {
|
||||
return running.unhide()
|
||||
}
|
||||
return running.hide()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func quit(_ app: RunningApp) -> Bool {
|
||||
guard let running = NSRunningApplication(processIdentifier: app.id) else { return false }
|
||||
return running.terminate()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user