Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61d2689cbc | ||
|
|
8df3e2cd94 | ||
|
|
d9eddf799f | ||
|
|
dec8d60aa3 | ||
|
|
5eb2d0f661 | ||
|
|
5a4e2fbda2 |
@@ -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
|
||||
`enumerate()` routine is `nonisolated static` so `--self-test` can run it.
|
||||
- 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
|
||||
Recording or Apple Events usage.
|
||||
|
||||
@@ -115,14 +123,29 @@ no control over this, so the app creates its own `NSStatusItem` and:
|
||||
## Hiding other menu bar items
|
||||
|
||||
`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.
|
||||
Verified with `--diag-hide`:
|
||||
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:
|
||||
|
||||
- All third-party apps' icons go to x < 0 (cmux, Kapture, moomoo, WireGuard,
|
||||
TextInputMenuAgent, Now Playing) and so does Wi-Fi.
|
||||
- **Spotlight, Clock, Battery and Control Center stay**: macOS pins 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. Do not try to "fix" this; it is a macOS
|
||||
- Seed **250** (default) puts the boundary just left of Wi-Fi, so every
|
||||
third-party app icon goes to x < 0 (cmux, Kapture, moomoo, WireGuard,
|
||||
TextInputMenuAgent, Now Playing) while **Wi-Fi (x≈1173), Spotlight, Clock,
|
||||
Battery, Control Center and our hamburger stay visible**.
|
||||
- Verified with `--diag-hide` across positions 100–400. Below ~200 Wi-Fi gets
|
||||
hidden too; above ~300 app icons (Now Playing, TextInput) leak back.
|
||||
- `debug.spacerPos` (Double) overrides the seed for tuning without rebuilding.
|
||||
- Pressing a tray tile calls `MenuBarItemManager.press` (`kAXPressAction`, then
|
||||
`kAXShowMenuAction`). Because a hidden item sits off-screen, its menu would
|
||||
open off-screen; `AppDelegate` therefore calls `MenuBarHider.suspend()`
|
||||
(`popoverWillShow`) to reveal items while the panel is open and `resume()`
|
||||
0.6s after `popoverDidClose`. `suspend`/`resume` do not change the user's
|
||||
`enabled` preference.
|
||||
- **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
|
||||
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
|
||||
constraint, not a bug.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -28,20 +28,25 @@ still works.
|
||||
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
|
||||
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
|
||||
the app dies, macOS removes the spacer automatically.
|
||||
they only live in this panel. It is **on by default** (the choice is
|
||||
remembered), always undone on quit, and if the app dies macOS removes the spacer
|
||||
automatically.
|
||||
|
||||
What it can and cannot hide:
|
||||
|
||||
- It hides **other third-party apps' icons** (and can also catch some Apple
|
||||
items such as Wi-Fi).
|
||||
- It **cannot** hide **Spotlight, Clock, Battery, or Control Center**. macOS
|
||||
reserves the far right of the menu bar for those, and a third-party app cannot
|
||||
place its item to their right, so there is nothing to push them with. (This is
|
||||
the same limit Bartender and Ice hit; they cover the area with their own
|
||||
overlay bar instead.)
|
||||
- It hides **other third-party apps' icons** (cmux, Kapture, WireGuard, input
|
||||
source, Now Playing, …) so only your apps' icons leave the bar.
|
||||
- It **keeps Wi-Fi, Spotlight, Clock, Battery and Control Center**. The spacer's
|
||||
boundary is tuned (preferred position 250) to fall just left of Wi-Fi, so the
|
||||
Apple items on the right stay visible. macOS reserves that far-right area: a
|
||||
third-party item can never be placed to the right of it, so those icons cannot
|
||||
be pushed away even in principle. (Bartender and Ice hit the same wall and
|
||||
cover the area with their own overlay bar instead.)
|
||||
- Newly launched apps are placed to the left of the spacer, so they are hidden
|
||||
automatically.
|
||||
- Advanced: set `defaults write dev.tzgyn.simpletopbar debug.spacerPos -float <n>`
|
||||
to move the boundary (larger = keep more icons; > ~400 starts leaking app
|
||||
icons back into the bar).
|
||||
|
||||
## Install with Homebrew
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ struct SimpleTopBarApp: App {
|
||||
/// full menu bar, a brand new item lands under the notch and is invisible;
|
||||
/// writing `NSStatusItem Preferred Position <name>` into defaults puts it in a
|
||||
/// visible slot, and the autosave name lets the user's own drag persist.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||
private var statusItem: NSStatusItem?
|
||||
private var popover: NSPopover?
|
||||
|
||||
@@ -43,8 +43,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
AppListModel.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()
|
||||
MenuBarHider.shared.prepare()
|
||||
|
||||
if CommandLine.arguments.contains("--diag") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
@@ -101,6 +103,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let popover = NSPopover()
|
||||
popover.behavior = .transient
|
||||
popover.animates = false
|
||||
popover.delegate = self
|
||||
popover.contentViewController = NSHostingController(
|
||||
rootView: TopBarView(
|
||||
model: AppListModel.shared,
|
||||
@@ -111,6 +114,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
self.popover = popover
|
||||
}
|
||||
|
||||
// Reveal the hidden menu bar items while the panel is open so pressing a
|
||||
// tray icon opens that app's menu at its real, on-screen position.
|
||||
func popoverWillShow(_ notification: Notification) {
|
||||
MenuBarHider.shared.suspend()
|
||||
}
|
||||
|
||||
func popoverDidClose(_ notification: Notification) {
|
||||
// Small delay so a menu just opened from a tray tile is fully on screen
|
||||
// before the bar contracts again.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
MenuBarHider.shared.resume()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func togglePopover() {
|
||||
guard let popover, let button = statusItem?.button else { return }
|
||||
if popover.isShown {
|
||||
|
||||
@@ -8,7 +8,7 @@ struct MenuBarItem: Identifiable, Equatable {
|
||||
let pid: pid_t
|
||||
let appName: String
|
||||
let title: String
|
||||
let appIcon: NSImage
|
||||
let icon: NSImage
|
||||
let element: AXUIElement
|
||||
|
||||
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
||||
@@ -68,17 +68,29 @@ final class MenuBarItemManager: ObservableObject {
|
||||
guard let bar = extrasMenuBar(of: axApp),
|
||||
let children = children(of: bar) else { continue }
|
||||
|
||||
var seenTitles = Set<String>()
|
||||
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)
|
||||
?? stringAttribute(child, kAXTitleAttribute)
|
||||
?? app.localizedName
|
||||
?? "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(
|
||||
id: "\(pid)-\(index)",
|
||||
pid: pid,
|
||||
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||
title: title,
|
||||
appIcon: app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage(),
|
||||
icon: Self.icon(forTitle: title, app: app),
|
||||
element: child
|
||||
))
|
||||
}
|
||||
@@ -101,6 +113,52 @@ final class MenuBarItemManager: ObservableObject {
|
||||
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? {
|
||||
@@ -139,10 +197,10 @@ final class MenuBarItemManager: ObservableObject {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// panel. Reliable only when SimpleTopBar starts at login (its spacer then sits
|
||||
/// to the right of later-launched apps' items). Always restored on quit.
|
||||
/// panel. On by default; the choice is persisted. Always restored on quit, and
|
||||
/// a crash cleans up automatically because status items die with the process.
|
||||
@MainActor
|
||||
final class MenuBarHider: ObservableObject {
|
||||
static let shared = MenuBarHider()
|
||||
@@ -150,45 +208,71 @@ final class MenuBarHider: ObservableObject {
|
||||
/// Width that pushes the neighbouring items off the visible menu bar.
|
||||
static let hiddenLength: CGFloat = 10_000
|
||||
|
||||
private static let defaultsKey = "hideMenuBarItems"
|
||||
|
||||
@Published private(set) var isHiding = false
|
||||
@Published var enabled = false {
|
||||
@Published var enabled: Bool {
|
||||
didSet {
|
||||
guard enabled != oldValue else { return }
|
||||
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
|
||||
apply(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
func prepare() {
|
||||
guard spacer == nil else { return }
|
||||
let name = "SimpleTopBarHider"
|
||||
let key = "NSStatusItem Preferred Position \(name)"
|
||||
// Seed just left of the hamburger so growing the spacer pushes the
|
||||
// third-party icons on its left off-screen while ours stays put.
|
||||
if UserDefaults.standard.object(forKey: key) == nil {
|
||||
UserDefaults.standard.set(150.0, forKey: key)
|
||||
// The boundary between "hidden" and "kept" falls just left of Wi-Fi at
|
||||
// ~250, so the other apps hide while Apple's Wi-Fi/Clock/Battery/Control
|
||||
// Center/Spotlight stay visible. `debug.spacerPos` overrides the seed.
|
||||
if let override = UserDefaults.standard.object(forKey: "debug.spacerPos") as? Double {
|
||||
UserDefaults.standard.set(override, forKey: key)
|
||||
} else if UserDefaults.standard.object(forKey: key) == nil {
|
||||
UserDefaults.standard.set(250.0, forKey: key)
|
||||
}
|
||||
let item = NSStatusBar.system.statusItem(withLength: 0)
|
||||
item.autosaveName = name
|
||||
item.button?.isHidden = true
|
||||
spacer = item
|
||||
apply(enabled)
|
||||
}
|
||||
|
||||
private func apply(_ hiding: Bool) {
|
||||
prepare()
|
||||
spacer?.length = hiding ? Self.hiddenLength : 0
|
||||
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() {
|
||||
spacer?.length = 0
|
||||
isHiding = false
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,12 +80,20 @@ func runSelfTest() {
|
||||
check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element")
|
||||
|
||||
let element = AXUIElementCreateSystemWide()
|
||||
let first = 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", appIcon: NSImage(), element: element)
|
||||
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
||||
let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element)
|
||||
let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element)
|
||||
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element)
|
||||
check(first == same, "menu bar items compare by id and title")
|
||||
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")
|
||||
|
||||
let authorized = AXIsProcessTrusted()
|
||||
print("accessibility authorized=\(authorized)")
|
||||
if authorized {
|
||||
|
||||
@@ -115,7 +115,7 @@ struct TopBarView: View {
|
||||
.controlSize(.mini)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 6)
|
||||
.help("Best-effort: pushes other apps' icons off the menu bar so they only live here. Apple's Spotlight, Clock, Battery and Control Center stay pinned on the right and cannot be moved by any third-party app.")
|
||||
.help("Best-effort: pushes other apps' icons off the menu bar so they only live here. Apple's Wi-Fi, Spotlight, Clock, Battery and Control Center stay visible; macOS won't let any third-party app move them.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,8 +245,11 @@ private struct MenuBarTile: View {
|
||||
Button {
|
||||
menuBar.press(item)
|
||||
} 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()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(6)
|
||||
.background(
|
||||
|
||||
Reference in New Issue
Block a user