13 Commits
Author SHA1 Message Date
TZGyn 8d795d0a28 Give up focus when opening a tray menu
Closing the panel was not enough: SimpleTopBar stayed frontmost, so
Spotlight's search field never appeared. Deactivate the app when a tray
item is selected so transient panels can take focus, and allow a little
more time before the bar re-hides.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:30:20 +08:00
TZGyn e465acba12 Close the panel when a tray item is selected
Selecting a tile opened the app's menu while SimpleTopBar's own panel
stayed on top of it, and could leave menus such as Spotlight stranded
behind it. Dismiss the panel first so the selected menu is unobstructed.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:26:09 +08:00
TZGyn 29157c77fb Split the tray into Apps and System sections
Control Center items sat in the same grid as third-party app icons, which
was hard to scan. Group them so Apple's Wi-Fi, Battery, Clock and friends
are listed separately from your apps' icons.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:18:28 +08:00
TZGyn b0671abed8 Set default release version to 0.4.1 2026-09-27 10:14:00 +08:00
TZGyn 569abe967f Revert "Hide menu bar icons with a cover instead of moving them off screen"
This reverts commit 3e57efe312.
2026-09-27 10:13:10 +08:00
TZGyn 3e57efe312 Hide menu bar icons with a cover instead of moving them off screen
Sliding the icons off the screen meant bringing them all back just to
open one menu, which flickered. Draw a frosted window over the other
apps' icons instead: they keep their real positions, so tapping a tile
opens its menu in place with no reveal, and the cover leaves a gap so
the hamburger is never hidden.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 03:06:15 +08:00
TZGyn 2312e09105 Shorten the icon reveal when opening a tray menu
The menu is its own window anchored where the item was revealed, so it
survives if the bar re-hides immediately. Revealing for ~0.3s instead of
over a second makes the flash far less noticeable while still opening
the menu in view.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:53:41 +08:00
TZGyn 9c5416d27e Reveal tray icons only for the moment a menu is opened
Keeping every icon on the menu bar for as long as the panel was open
looked cluttered. Reveal them just long enough to open the clicked
item's menu, then hide again, so the panel stays clean.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:47:20 +08:00
TZGyn 61d2689cbc Reveal hidden tray icons while the panel is open
Pressing a tray tile did nothing visible because the item was hidden
off-screen, so its menu opened where it could not be seen. Temporarily
put the icons back on the menu bar while the panel is open, so clicking
a tile opens that app's real menu in view.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:25:50 +08:00
TZGyn 8df3e2cd94 Keep menu bar tray icons at their natural aspect ratio
Forcing every icon into a square stretched wide symbols such as the
battery into a blocky shape, making them hard to recognise. Scale to fit
instead so wide icons stay long and short.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:21:19 +08:00
TZGyn d9eddf799f Give Control Center items distinct icons
Every Control Center module shares one app icon, so the tray was a wall
of identical tiles. Map each item's title to its own SF Symbol so Wi-Fi,
Battery, Now Playing and friends are distinguishable at a glance.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:18:57 +08:00
TZGyn dec8d60aa3 Hide other menu bar icons by default
The point of the app is a single hamburger that replaces the tray, so
hiding the other icons should be the out-of-the-box state. Remember the
user's choice, and create the hamburger before the hider so it is not
itself pushed off-screen.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:15:01 +08:00
TZGyn 5eb2d0f661 Stop listing Control Center's off-screen module duplicates
Control Center reports every module it owns, so the tray filled with
identical tiles for items that are not actually in the menu bar. Keep
only items whose accessibility position is in the bar and de-duplicate
by title, so each tray icon appears once.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:11:57 +08:00
7 changed files with 300 additions and 50 deletions
+27 -1
View File
@@ -38,6 +38,14 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
or per-element, so every helper returns `nil` instead of force-unwrapping. The or per-element, so every helper returns `nil` instead of force-unwrapping. The
`enumerate()` routine is `nonisolated static` so `--self-test` can run it. `enumerate()` routine is `nonisolated static` so `--self-test` can run it.
- Items are keyed by `pid-index`; `MenuBarItem` equality ignores the AX element. - Items are keyed by `pid-index`; `MenuBarItem` equality ignores the AX element.
- Control Center is special: its extras menu bar lists **every module it owns**,
including ones not in the bar, which are parked off-screen at `y≈982`.
`enumerate()` drops items whose AX position `y > 60` and de-duplicates by
title per app, otherwise the tray fills with identical "Control Center" tiles.
- Icons: every Control Center module shares the same app icon, so
`symbolName(for:)` maps titles (Wi‑Fi, Battery, Now Playing, …) to distinct SF
Symbols, falling back to the owning app's icon. Note the non-breaking hyphen
in "Wi‑Fi" (`\u{2011}`) is normalised before matching.
- Reading another app's UI is the only TCC permission needed; there is no Screen - Reading another app's UI is the only TCC permission needed; there is no Screen
Recording or Apple Events usage. Recording or Apple Events usage.
@@ -115,7 +123,8 @@ no control over this, so the app creates its own `NSStatusItem` and:
## Hiding other menu bar items ## Hiding other menu bar items
`MenuBarHider` creates a zero-width spacer `NSStatusItem` at launch and grows its `MenuBarHider` creates a zero-width spacer `NSStatusItem` at launch and grows its
length to `hiddenLength` (10_000) to push the icons on its left off-screen. Its length to `hiddenLength` (10_000) to push the icons on its left off-screen. It is
**on by default** (`hideMenuBarItems` defaults to true, then persisted). Its
preferred position controls where the hidden/kept boundary lands: preferred position controls where the hidden/kept boundary lands:
- Seed **250** (default) puts the boundary just left of Wi-Fi, so every - Seed **250** (default) puts the boundary just left of Wi-Fi, so every
@@ -125,6 +134,23 @@ preferred position controls where the hidden/kept boundary lands:
- Verified with `--diag-hide` across positions 100–400. Below ~200 Wi-Fi gets - Verified with `--diag-hide` across positions 100–400. Below ~200 Wi-Fi gets
hidden too; above ~300 app icons (Now Playing, TextInput) leak back. hidden too; above ~300 app icons (Now Playing, TextInput) leak back.
- `debug.spacerPos` (Double) overrides the seed for tuning without rebuilding. - `debug.spacerPos` (Double) overrides the seed for tuning without rebuilding.
- Pressing a tray tile calls `MenuBarItemManager.press`. A hidden item sits
off-screen, and its menu opens off-screen too (verified with `--diag-press`:
the menu window lands at negative x). So press briefly `suspend()`s the hider,
waits ~0.2s for the layout to settle, performs the accessibility action
(`kAXPressAction`, then `kAXShowMenuAction`), and `resume()`s ~0.1s later. The
menu is its own window anchored at the item's on-screen spot, so it survives
re-hiding.
- `press` first calls `onWillPress`, which `AppDelegate` wires to close the
popover **and** `NSApp.deactivate()`. Closing alone left our app frontmost, so
transient items (Spotlight's search field) still would not appear.
- **Cannot reveal a single icon.** The spacer hides a contiguous block to its
left, so a press can only reveal the whole block. Isolating one item would
need either per-item ordering control (not available) or covering the icons
with an overlay window instead of moving them off-screen.
- **Order matters**: `AppDelegate` must call `setupStatusItem()` *before*
`MenuBarHider.shared.prepare()`. If the hider contracts the bar first, the
hamburger is created into the collapsed space and lands off-screen.
- **Wi-Fi/Spotlight/Clock/Battery/Control Center cannot be hidden**: macOS pins - **Wi-Fi/Spotlight/Clock/Battery/Control Center cannot be hidden**: macOS pins
them to the far right and a third-party item can never be inserted to their them to the far right and a third-party item can never be inserted to their
right, so the spacer has nothing to push them with. This is a macOS right, so the spacer has nothing to push them with. This is a macOS
+6 -5
View File
@@ -5,9 +5,9 @@ collects every other app's menu bar (tray) icons, plus every app running on your
Mac, into three sections: Mac, into three sections:
- **Menu Bar** — a tray of all other apps' menu bar items, discovered through - **Menu Bar** — a tray of all other apps' menu bar items, discovered through
the Accessibility API. Click one to open its real menu. Optionally **hide the the Accessibility API. It is split into **Apps** and **System** sections.
items from the menu bar** so they only live inside this panel (best-effort; Click one to open its real menu. Optionally **hide the items from the menu
see below). bar** so they only live inside this panel (best-effort; see below).
- **Apps** — regular apps that live in the Dock. - **Apps** — regular apps that live in the Dock.
- **Background Apps** — accessory/agent apps without a Dock icon. This section - **Background Apps** — accessory/agent apps without a Dock icon. This section
is a **dropdown**: it stays collapsed to a single header, and when you expand is a **dropdown**: it stays collapsed to a single header, and when you expand
@@ -28,8 +28,9 @@ still works.
macOS has no public API to move another app's status item. The **Hide items from macOS has no public API to move another app's status item. The **Hide items from
the menu bar** toggle uses the spacer technique: a zero-width status item is the menu bar** toggle uses the spacer technique: a zero-width status item is
created at launch whose length is expanded to push the other icons off-screen so created at launch whose length is expanded to push the other icons off-screen so
they only live in this panel. It is off by default and always undone on quit; if they only live in this panel. It is **on by default** (the choice is
the app dies, macOS removes the spacer automatically. remembered), always undone on quit, and if the app dies macOS removes the spacer
automatically.
What it can and cannot hide: What it can and cannot hide:
+37 -1
View File
@@ -43,8 +43,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
AppListModel.shared.start() AppListModel.shared.start()
MenuBarItemManager.shared.start() MenuBarItemManager.shared.start()
MenuBarHider.shared.prepare() // Create the hamburger before the hider so it is placed in the visible
// part of the bar; the hider then contracts the space around it.
setupStatusItem() setupStatusItem()
MenuBarHider.shared.prepare()
if CommandLine.arguments.contains("--diag-press") {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
MenuBarHider.shared.suspend()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.25) {
if let item = MenuBarItemManager.enumerate().first(where: {
$0.title.lowercased().contains("wireguard")
}) {
NSLog("diag-press: pressing \(item.appName) after brief reveal")
_ = AXUIElementPerformAction(item.element, kAXPressAction as CFString)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.45) {
MenuBarHider.shared.resume()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 4.5) {
dumpWindows()
}
return
}
if CommandLine.arguments.contains("--diag") { if CommandLine.arguments.contains("--diag") {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
@@ -109,6 +132,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
) )
) )
self.popover = popover self.popover = popover
// Selecting a tray item must dismiss our panel and give up focus,
// otherwise the panel stays on top of the menu and our app stays
// frontmost, which stops transient items (Spotlight's search field)
// from appearing at all.
Task { @MainActor [weak self] in
MenuBarItemManager.shared.onWillPress = {
self?.popover?.performClose(nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
NSApp.deactivate()
}
}
}
} }
@objc private func togglePopover() { @objc private func togglePopover() {
+136 -14
View File
@@ -8,7 +8,9 @@ struct MenuBarItem: Identifiable, Equatable {
let pid: pid_t let pid: pid_t
let appName: String let appName: String
let title: String let title: String
let appIcon: NSImage let icon: NSImage
/// True for Apple's own items (Control Center, Spotlight, …).
let isSystem: Bool
let element: AXUIElement let element: AXUIElement
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool { static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
@@ -28,6 +30,9 @@ final class MenuBarItemManager: ObservableObject {
private var observers: [NSObjectProtocol] = [] private var observers: [NSObjectProtocol] = []
/// Called just before pressing an item so the owner can close its panel.
var onWillPress: (() -> Void)?
private init() {} private init() {}
func start() { func start() {
@@ -68,17 +73,30 @@ final class MenuBarItemManager: ObservableObject {
guard let bar = extrasMenuBar(of: axApp), guard let bar = extrasMenuBar(of: axApp),
let children = children(of: bar) else { continue } let children = children(of: bar) else { continue }
var seenTitles = Set<String>()
for (index, child) in children.enumerated() { for (index, child) in children.enumerated() {
// Control Center reports every module it owns, including the
// ones not in the menu bar, which are parked off-screen around
// y=982. Keep only items actually sitting in the bar.
if let position = pointAttribute(child, kAXPositionAttribute), position.y > 60 {
continue
}
let title = stringAttribute(child, kAXDescriptionAttribute) let title = stringAttribute(child, kAXDescriptionAttribute)
?? stringAttribute(child, kAXTitleAttribute) ?? stringAttribute(child, kAXTitleAttribute)
?? app.localizedName ?? app.localizedName
?? "Item" ?? "Item"
// Several Control Center modules share the generic title
// "Control Center"; show each distinct title once per app.
if !seenTitles.insert(title).inserted { continue }
found.append(MenuBarItem( found.append(MenuBarItem(
id: "\(pid)-\(index)", id: "\(pid)-\(index)",
pid: pid, pid: pid,
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown", appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
title: title, title: title,
appIcon: app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage(), icon: Self.icon(forTitle: title, app: app),
isSystem: Self.isSystemBundle(app.bundleIdentifier),
element: child element: child
)) ))
} }
@@ -91,16 +109,97 @@ final class MenuBarItemManager: ObservableObject {
} }
} }
/// Opens the real menu bar item by pressing its accessibility element, so /// Opens the real menu bar item, so its menu/popover appears.
/// its menu/popover appears at its true screen location. ///
@discardableResult /// A hidden item sits off-screen, and its menu would open off-screen too.
func press(_ item: MenuBarItem) -> Bool { /// So briefly reveal the items, let the layout settle, press the item, then
/// hide again shortly after. The reveal is momentary rather than lasting the
/// whole time the panel is open.
func press(_ item: MenuBarItem) {
// Close our own panel first, otherwise it stays open on top of the
// menu that is about to appear (and can leave menus behind it).
onWillPress?()
let hider = MenuBarHider.shared
let needsReveal = hider.isHiding
guard needsReveal else {
_ = performPress(item)
return
}
hider.suspend()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
_ = self.performPress(item)
// The menu is its own window, positioned at the item's on-screen
// spot, so the bar can be re-hidden shortly after.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
hider.resume()
}
}
}
private func performPress(_ item: MenuBarItem) -> Bool {
if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success { if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success {
return true return true
} }
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
} }
// MARK: - Classification
/// Apple's own menu bar items (Control Center, Spotlight, the input-source
/// agent, …) live under `com.apple.*` bundles; everything else is a third
/// party app.
nonisolated static func isSystemBundle(_ bundleID: String?) -> Bool {
guard let bundleID else { return false }
return bundleID.hasPrefix("com.apple.")
}
// MARK: - Icons
/// Picks an icon for a menu bar item. Control Center owns many unrelated
/// modules, and they all share the Control Center app icon, so map their
/// titles to distinct SF Symbols for readability. Anything unknown falls
/// back to the owning app's icon.
nonisolated static func icon(forTitle title: String, app: NSRunningApplication) -> NSImage {
if let symbol = symbolName(for: title),
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) {
image.isTemplate = true
return image
}
return app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
}
/// Keyword table mapping a menu bar item's title/description to an SF Symbol.
nonisolated static func symbolName(for title: String) -> String? {
// Control Center uses a non-breaking hyphen in "Wi‑Fi".
let text = title.lowercased()
.replacingOccurrences(of: "\u{2010}", with: "-")
.replacingOccurrences(of: "\u{2011}", with: "-")
let table: [(matches: [String], symbol: String)] = [
(["wi-fi", "wifi"], "wifi"),
(["battery"], "battery.100"),
(["clock", "time"], "clock"),
(["now playing"], "play.circle"),
(["focus"], "moon.fill"),
(["sound", "volume", "audio"], "speaker.wave.2"),
(["airdrop"], "dot.radiowaves.left.and.right"),
(["bluetooth"], "dot.radiowaves.left.and.right"),
(["mirroring"], "rectangle.on.rectangle"),
(["search", "spotlight"], "magnifyingglass"),
(["siri"], "waveform"),
(["keyboard"], "keyboard"),
(["stage manager"], "rectangle.3.group"),
(["brightness", "display"], "sun.max"),
(["control center"], "switch.2"),
(["notification"], "bell"),
(["vpn"], "lock.shield"),
]
for entry in table where entry.matches.contains(where: { text.contains($0) }) {
return entry.symbol
}
return nil
}
// MARK: - AX helpers // MARK: - AX helpers
nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? { nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? {
@@ -139,10 +238,10 @@ final class MenuBarItemManager: ObservableObject {
} }
/// Best-effort menu bar item hiding. A zero-width spacer status item is created /// Best-effort menu bar item hiding. A zero-width spacer status item is created
/// at launch; expanding its length to a huge value pushes the items to its left /// at launch; expanding its length to a huge value pushes the other apps' icons
/// off the visible menu bar, so they can only be reached through SimpleTopBar's /// off the visible menu bar, so they can only be reached through SimpleTopBar's
/// panel. Reliable only when SimpleTopBar starts at login (its spacer then sits /// panel. On by default; the choice is persisted. Always restored on quit, and
/// to the right of later-launched apps' items). Always restored on quit. /// a crash cleans up automatically because status items die with the process.
@MainActor @MainActor
final class MenuBarHider: ObservableObject { final class MenuBarHider: ObservableObject {
static let shared = MenuBarHider() static let shared = MenuBarHider()
@@ -150,17 +249,24 @@ final class MenuBarHider: ObservableObject {
/// Width that pushes the neighbouring items off the visible menu bar. /// Width that pushes the neighbouring items off the visible menu bar.
static let hiddenLength: CGFloat = 10_000 static let hiddenLength: CGFloat = 10_000
private static let defaultsKey = "hideMenuBarItems"
@Published private(set) var isHiding = false @Published private(set) var isHiding = false
@Published var enabled = false { @Published var enabled: Bool {
didSet { didSet {
guard enabled != oldValue else { return } guard enabled != oldValue else { return }
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
apply(enabled) apply(enabled)
} }
} }
private var spacer: NSStatusItem? private var spacer: NSStatusItem?
private init() {} private init() {
// Hide by default: the menu bar then shows only SimpleTopBar's hamburger
// plus the Apple items macOS will not let us move.
enabled = UserDefaults.standard.object(forKey: Self.defaultsKey) as? Bool ?? true
}
/// Create the spacer early (at launch) so it lands as far right as possible. /// Create the spacer early (at launch) so it lands as far right as possible.
func prepare() { func prepare() {
@@ -179,19 +285,35 @@ final class MenuBarHider: ObservableObject {
item.autosaveName = name item.autosaveName = name
item.button?.isHidden = true item.button?.isHidden = true
spacer = item spacer = item
apply(enabled)
} }
private func apply(_ hiding: Bool) { private func apply(_ hiding: Bool) {
prepare()
spacer?.length = hiding ? Self.hiddenLength : 0 spacer?.length = hiding ? Self.hiddenLength : 0
isHiding = hiding isHiding = hiding
} }
/// Put the menu bar back the way we found it. Called on quit. /// Temporarily reveal the items (e.g. while the panel is open) so pressing
/// a tray icon opens its menu at its real, on-screen position. Keeps the
/// user's `enabled` preference untouched.
func suspend() {
guard enabled else { return }
spacer?.length = 0
isHiding = false
}
/// Restore hiding after a `suspend()`.
func resume() {
guard enabled else { return }
spacer?.length = Self.hiddenLength
isHiding = true
}
/// Put the menu bar back the way we found it. Called on quit. Leaves the
/// user's preference intact.
func restore() { func restore() {
spacer?.length = 0 spacer?.length = 0
isHiding = false isHiding = false
enabled = false
} }
} }
+40 -4
View File
@@ -1,5 +1,28 @@
import AppKit import AppKit
import ApplicationServices import ApplicationServices
import CoreGraphics
/// Diagnostic used by `open SimpleTopBar.app --args --diag-press`. Presses a
/// hidden item's accessibility element without revealing it and dumps the
/// resulting high-level windows, to see where its menu actually opens.
func dumpWindows() {
var lines: [String] = []
if let list = CGWindowListCopyWindowInfo([], kCGNullWindowID) as? [[String: Any]] {
for window in list {
let layer = window[kCGWindowLayer as String] as? Int ?? -1
guard layer > 20 else { continue }
let owner = window[kCGWindowOwnerName as String] as? String ?? "?"
let bounds = window[kCGWindowBounds as String] as? [String: Any] ?? [:]
let x = bounds["X"] as? Double ?? 0
let y = bounds["Y"] as? Double ?? 0
let width = bounds["Width"] as? Double ?? 0
let height = bounds["Height"] as? Double ?? 0
lines.append("\(owner) layer=\(layer) frame=\(Int(x)),\(Int(y)) \(Int(width))x\(Int(height))")
}
}
try? lines.joined(separator: "\n").write(toFile: "/tmp/simpletopbar-windows.txt", atomically: true, encoding: .utf8)
exit(0)
}
/// Diagnostic used by `open SimpleTopBar.app --args --dump-items`. Writes the /// Diagnostic used by `open SimpleTopBar.app --args --dump-items`. Writes the
/// live Accessibility view of the menu bar to /tmp so it can be inspected /// live Accessibility view of the menu bar to /tmp so it can be inspected
@@ -12,7 +35,7 @@ func dumpMenuBarItems() {
lines.append("count=\(items.count)") lines.append("count=\(items.count)")
for item in items { for item in items {
let pos = MenuBarItemManager.pointAttribute(item.element, kAXPositionAttribute) let pos = MenuBarItemManager.pointAttribute(item.element, kAXPositionAttribute)
lines.append("\(item.appName) | \(item.title) | pid=\(item.pid) | id=\(item.id) | pos=\(pos.map { "\(Int($0.x)),\(Int($0.y))" } ?? "?")") lines.append("\(item.appName) | \(item.title) | pid=\(item.pid) | id=\(item.id) | system=\(item.isSystem) | pos=\(pos.map { "\(Int($0.x)),\(Int($0.y))" } ?? "?")")
} }
let own = MenuBarItemManager.enumerate(includeOwn: true).filter { let own = MenuBarItemManager.enumerate(includeOwn: true).filter {
$0.pid == ProcessInfo.processInfo.processIdentifier $0.pid == ProcessInfo.processInfo.processIdentifier
@@ -80,12 +103,25 @@ func runSelfTest() {
check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element") check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element")
let element = AXUIElementCreateSystemWide() let element = AXUIElementCreateSystemWide()
let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element) let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), isSystem: false, element: element)
let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element) let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), isSystem: false, element: element)
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element) let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: NSImage(), isSystem: false, element: element)
check(first == same, "menu bar items compare by id and title") check(first == same, "menu bar items compare by id and title")
check(first != different, "different ids are not equal") check(first != different, "different ids are not equal")
// Control Center items map to distinct symbols instead of one shared icon.
check(MenuBarItemManager.symbolName(for: "Wi\u{2011}Fi, connected, 3 bars") == "wifi", "wi-fi maps to wifi symbol")
check(MenuBarItemManager.symbolName(for: "Battery") == "battery.100", "battery maps to battery symbol")
check(MenuBarItemManager.symbolName(for: "Now Playing") == "play.circle", "now playing maps to play symbol")
check(MenuBarItemManager.symbolName(for: "Control Center") == "switch.2", "control center maps to switch symbol")
check(MenuBarItemManager.symbolName(for: "Search") == "magnifyingglass", "search maps to magnifier symbol")
check(MenuBarItemManager.symbolName(for: "cmux") == nil, "unknown titles fall back to the app icon")
// Tray sections: Apple bundles are "system", everything else is an app.
check(MenuBarItemManager.isSystemBundle("com.apple.controlcenter"), "apple bundles classify as system")
check(!MenuBarItemManager.isSystemBundle("com.cmux.app"), "third-party bundles classify as apps")
check(!MenuBarItemManager.isSystemBundle(nil), "unknown bundles are treated as apps")
let authorized = AXIsProcessTrusted() let authorized = AXIsProcessTrusted()
print("accessibility authorized=\(authorized)") print("accessibility authorized=\(authorized)")
if authorized { if authorized {
+53 -24
View File
@@ -60,23 +60,6 @@ struct TopBarView: View {
/// The hamburger tray: every other app's menu bar item lives here. /// The hamburger tray: every other app's menu bar item lives here.
private var menuBarSection: some View { private var menuBarSection: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Text("MENU BAR")
.font(.caption2)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
Spacer()
Text("\(menuBar.items.count)")
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Capsule().fill(Color.secondary.opacity(0.15)))
}
.padding(.horizontal, 12)
.padding(.top, 4)
.padding(.bottom, 4)
if !menuBar.authorized { if !menuBar.authorized {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Grant Accessibility to collect and open menu bar items.") Text("Grant Accessibility to collect and open menu bar items.")
@@ -98,12 +81,9 @@ struct TopBarView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 4) .padding(.vertical, 4)
} else { } else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) { trayGroup(title: "Apps", items: appTrayItems)
ForEach(menuBar.items) { item in Divider().padding(.vertical, 2)
MenuBarTile(item: item, menuBar: menuBar) trayGroup(title: "System", items: systemTrayItems)
}
}
.padding(.horizontal, 12)
} }
if menuBar.authorized { if menuBar.authorized {
@@ -120,6 +100,52 @@ struct TopBarView: View {
} }
} }
/// Third-party apps' menu bar icons.
private var appTrayItems: [MenuBarItem] {
menuBar.items.filter { !$0.isSystem }
}
/// Apple's own menu bar items (Control Center, Spotlight, …).
private var systemTrayItems: [MenuBarItem] {
menuBar.items.filter { $0.isSystem }
}
private func trayGroup(title: String, items: [MenuBarItem]) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Text(title.uppercased())
.font(.caption2)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
Spacer()
Text("\(items.count)")
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Capsule().fill(Color.secondary.opacity(0.15)))
}
.padding(.horizontal, 12)
.padding(.top, 4)
.padding(.bottom, 4)
if items.isEmpty {
Text("None")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.horizontal, 12)
.padding(.bottom, 4)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) {
ForEach(items) { item in
MenuBarTile(item: item, menuBar: menuBar)
}
}
.padding(.horizontal, 12)
}
}
}
private func section(title: String, apps: [RunningApp], emptyText: String) -> some View { private func section(title: String, apps: [RunningApp], emptyText: String) -> some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(title.uppercased()) Text(title.uppercased())
@@ -245,8 +271,11 @@ private struct MenuBarTile: View {
Button { Button {
menuBar.press(item) menuBar.press(item)
} label: { } label: {
Image(nsImage: item.appIcon) // Preserve the symbol's aspect ratio: forcing wide symbols like the
// battery into a square stretches them into a blocky shape.
Image(nsImage: item.icon)
.resizable() .resizable()
.scaledToFit()
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
.padding(6) .padding(6)
.background( .background(
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
VERSION="${1:-0.2.3}" VERSION="${1:-0.4.4}"
swift build -c release swift build -c release