5 Commits
Author SHA1 Message Date
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
5 changed files with 163 additions and 10 deletions
+15
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`.
`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.
@@ -130,6 +134,17 @@ 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.
- 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.
- **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.
+21
View File
@@ -48,6 +48,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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") {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
dumpMenuBarItems()
+89 -6
View File
@@ -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
))
}
@@ -103,16 +103,83 @@ final class MenuBarItemManager: ObservableObject {
}
}
/// Opens the real menu bar item by pressing its accessibility element, so
/// its menu/popover appears at its true screen location.
@discardableResult
func press(_ item: MenuBarItem) -> Bool {
/// Opens the real menu bar item, so its menu/popover appears.
///
/// A hidden item sits off-screen, and its menu would open off-screen too.
/// 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) {
let hider = MenuBarHider.shared
let needsReveal = hider.isHiding
guard needsReveal else {
_ = performPress(item)
return
}
hider.suspend()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
_ = self.performPress(item)
// The menu is its own window, positioned at the item's on-screen
// spot, so the bar can be re-hidden almost immediately.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
hider.resume()
}
}
}
private func performPress(_ item: MenuBarItem) -> Bool {
if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success {
return true
}
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? {
@@ -206,6 +273,22 @@ final class MenuBarHider: ObservableObject {
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
/// user's preference intact.
func restore() {
+34 -3
View File
@@ -1,5 +1,28 @@
import AppKit
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
/// live Accessibility view of the menu bar to /tmp so it can be inspected
@@ -80,12 +103,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 {
+4 -1
View File
@@ -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(