3 Commits
Author SHA1 Message Date
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
5 changed files with 105 additions and 7 deletions
+10
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.
@@ -130,6 +134,12 @@ 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` (`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* - **Order matters**: `AppDelegate` must call `setupStatusItem()` *before*
`MenuBarHider.shared.prepare()`. If the hider contracts the bar first, the `MenuBarHider.shared.prepare()`. If the hider contracts the bar first, the
hamburger is created into the collapsed space and lands off-screen. hamburger is created into the collapsed space and lands off-screen.
+16 -1
View File
@@ -17,7 +17,7 @@ struct SimpleTopBarApp: App {
/// full menu bar, a brand new item lands under the notch and is invisible; /// 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 /// writing `NSStatusItem Preferred Position <name>` into defaults puts it in a
/// visible slot, and the autosave name lets the user's own drag persist. /// 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 statusItem: NSStatusItem?
private var popover: NSPopover? private var popover: NSPopover?
@@ -103,6 +103,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let popover = NSPopover() let popover = NSPopover()
popover.behavior = .transient popover.behavior = .transient
popover.animates = false popover.animates = false
popover.delegate = self
popover.contentViewController = NSHostingController( popover.contentViewController = NSHostingController(
rootView: TopBarView( rootView: TopBarView(
model: AppListModel.shared, model: AppListModel.shared,
@@ -113,6 +114,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.popover = popover 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() { @objc private func togglePopover() {
guard let popover, let button = statusItem?.button else { return } guard let popover, let button = statusItem?.button else { return }
if popover.isShown { if popover.isShown {
+64 -2
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? {
@@ -206,6 +252,22 @@ final class MenuBarHider: ObservableObject {
isHiding = hiding isHiding = hiding
} }
/// 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 /// Put the menu bar back the way we found it. Called on quit. Leaves the
/// user's preference intact. /// user's preference intact.
func restore() { func restore() {
+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(