3 Commits
Author SHA1 Message Date
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
6 changed files with 93 additions and 18 deletions
+9 -1
View File
@@ -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`. 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 `enumerate()` drops items whose AX position `y > 60` and de-duplicates by
title per app, otherwise the tray fills with identical "Control Center" tiles. 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.
@@ -119,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
@@ -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 - 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.
- **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
+3 -2
View File
@@ -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:
+3 -1
View File
@@ -43,8 +43,10 @@ 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") { if CommandLine.arguments.contains("--diag") {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
+63 -10
View File
@@ -8,7 +8,7 @@ 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
let element: AXUIElement let element: AXUIElement
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool { static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
@@ -90,7 +90,7 @@ final class MenuBarItemManager: ObservableObject {
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),
element: child element: child
)) ))
} }
@@ -113,6 +113,52 @@ final class MenuBarItemManager: ObservableObject {
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success 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 // MARK: - AX helpers
nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? { 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 /// 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()
@@ -162,17 +208,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() {
@@ -191,19 +244,19 @@ 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. /// 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
} }
} }
+11 -3
View File
@@ -80,12 +80,20 @@ 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(), 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(), 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(), 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")
let authorized = AXIsProcessTrusted() let authorized = AXIsProcessTrusted()
print("accessibility authorized=\(authorized)") print("accessibility authorized=\(authorized)")
if authorized { if authorized {
+4 -1
View File
@@ -245,8 +245,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(