Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9eddf799f | ||
|
|
dec8d60aa3 |
@@ -42,6 +42,10 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
|
||||
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.
|
||||
|
||||
@@ -119,7 +123,8 @@ 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. 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:
|
||||
|
||||
- Seed **250** (default) puts the boundary just left of Wi-Fi, so every
|
||||
@@ -129,6 +134,9 @@ preferred position controls where the hidden/kept boundary lands:
|
||||
- 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.
|
||||
- **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
|
||||
|
||||
@@ -28,8 +28,9 @@ 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:
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -90,7 +90,7 @@ final class MenuBarItemManager: ObservableObject {
|
||||
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
|
||||
))
|
||||
}
|
||||
@@ -113,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? {
|
||||
@@ -151,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()
|
||||
@@ -162,17 +208,24 @@ 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() {
|
||||
@@ -191,19 +244,19 @@ final class MenuBarHider: ObservableObject {
|
||||
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.
|
||||
/// 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 {
|
||||
|
||||
@@ -245,7 +245,7 @@ private struct MenuBarTile: View {
|
||||
Button {
|
||||
menuBar.press(item)
|
||||
} label: {
|
||||
Image(nsImage: item.appIcon)
|
||||
Image(nsImage: item.icon)
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(6)
|
||||
|
||||
Reference in New Issue
Block a user