menu-bar/Sources/LilithMenuBar/MenuBarAgent.swift
Natalie 3a0cc66fa4 🔥 remove deprecated label case
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-06 00:07:22 -07:00

121 lines
3.9 KiB
Swift

#if canImport(AppKit)
import AppKit
/// A lightweight wrapper around `NSStatusItem` that manages a macOS menu bar icon.
///
/// Supports two modes:
/// - **Click mode**: A single click on the icon triggers a closure.
/// - **Menu mode**: Clicking the icon opens a dropdown `NSMenu`.
@MainActor
public final class MenuBarAgent: NSObject {
private var statusItem: NSStatusItem?
private let icon: MenuBarIcon
private var trampolines: [MenuItemTrampoline] = []
/// Keeps the previous generation of trampolines alive so that menu items
/// from a still-displayed menu can still dispatch their actions.
private var retiredTrampolines: [MenuItemTrampoline] = []
private enum Mode {
case click(() -> Void)
case menu([MenuBarItem])
}
private let mode: Mode
// MARK: - Initializers
/// Creates an agent in **click mode**: a single click on the status item triggers `clickAction`.
public init(icon: MenuBarIcon, clickAction: @escaping () -> Void) {
self.icon = icon
self.mode = .click(clickAction)
super.init()
}
/// Creates an agent in **menu mode**: clicking the status item opens a dropdown built from `menu`.
public init(icon: MenuBarIcon, menu: [MenuBarItem]) {
self.icon = icon
self.mode = .menu(menu)
super.init()
}
// MARK: - Lifecycle
/// Installs the status item into the system menu bar and optionally sets the activation policy.
///
/// - Parameter activationPolicy: The application activation policy. Defaults to `.accessory`
/// (no Dock icon, no main menu).
public func install(activationPolicy: NSApplication.ActivationPolicy = .accessory) {
NSApplication.shared.setActivationPolicy(activationPolicy)
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.button?.image = icon.resolve()
switch mode {
case .click(let handler):
let trampoline = MenuItemTrampoline(handler: handler)
trampolines.append(trampoline)
item.button?.target = trampoline
item.button?.action = #selector(MenuItemTrampoline.invoke(_:))
case .menu(let items):
item.menu = buildMenu(from: items)
}
statusItem = item
}
/// Removes the status item from the menu bar and releases associated resources.
public func uninstall() {
if let item = statusItem {
NSStatusBar.system.removeStatusItem(item)
}
statusItem = nil
trampolines.removeAll()
retiredTrampolines.removeAll()
}
/// Replaces the current icon with `newIcon`.
public func updateIcon(_ newIcon: MenuBarIcon) {
statusItem?.button?.image = newIcon.resolve()
}
/// Replaces the menu items on an already-installed status item.
/// This preserves the `NSStatusItem` so that the menu bar icon doesn't flicker
/// and action targets remain valid while the menu is open.
public func updateMenu(_ items: [MenuBarItem]) {
retiredTrampolines = trampolines
trampolines = []
statusItem?.menu = buildMenu(from: items)
}
// MARK: - Private
private func buildMenu(from items: [MenuBarItem]) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false
for item in items {
switch item {
case .action(let title, let key, let handler):
let trampoline = MenuItemTrampoline(handler: handler)
trampolines.append(trampoline)
let menuItem = NSMenuItem(
title: title,
action: #selector(MenuItemTrampoline.invoke(_:)),
keyEquivalent: key
)
menuItem.target = trampoline
menuItem.isEnabled = true
menu.addItem(menuItem)
case .separator:
menu.addItem(.separator())
}
}
return menu
}
}
#endif