Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e57efe312 | ||
|
|
2312e09105 | ||
|
|
9c5416d27e | ||
|
|
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.
|
||||
|
||||
@@ -112,18 +120,32 @@ no control over this, so the app creates its own `NSStatusItem` and:
|
||||
`/tmp/simpletopbar-items.txt`; use it to verify the icon is not under the notch
|
||||
(x should be ~1200, not ~765).
|
||||
|
||||
## Hiding other menu bar items
|
||||
## Hiding other menu bar items (cover)
|
||||
|
||||
`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`:
|
||||
`MenuBarHider` drives `MenuBarCover`, which hides other apps' icons by drawing
|
||||
our own frosted window ("cover") over them instead of moving them off-screen.
|
||||
Because the icons keep their real positions, `press` opens their menus in place
|
||||
with no reveal. Verified with `--diag-cover` (cover windows at level 26) and
|
||||
`--diag-press` (the WireGuard menu opens at a positive x, not off-screen).
|
||||
|
||||
- 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
|
||||
constraint, not a bug.
|
||||
- `MenuBarCover.shouldHide` covers everything except the Apple keep-list
|
||||
(Wi-Fi, Battery, Clock, Control Center, Spotlight/Search).
|
||||
- `runs(from:)` merges adjacent item frames into contiguous cover windows;
|
||||
`subtracting(_:from:)` carves out the hamburger so it is never covered
|
||||
(`MenuBarItemManager.refresh` passes `avoid:` = our own item's frame).
|
||||
- Frames come from AX (`kAXPositionAttribute` + `kAXSizeAttribute`) with a
|
||||
**top-left origin**; `windowFrame(for:primaryHeight:)` flips Y for NSWindow.
|
||||
- The cover window level is 26 (just above status items at 25, below menus at
|
||||
~101), so opened menus draw on top of it.
|
||||
- `ignoresMouseEvents = false` makes the covered strip swallow clicks so the
|
||||
hidden icons cannot be clicked through.
|
||||
|
||||
### Legacy: off-screen spacer
|
||||
|
||||
An earlier approach used a zero-width spacer `NSStatusItem` grown to width
|
||||
10_000 to push icons off the left edge. It is no longer used because it required
|
||||
a visible reveal to open a menu. Findings if it is ever revived: preferred
|
||||
position ~250 kept Wi-Fi while hiding apps, and `debug.spacerPos` overrode it.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -134,7 +156,7 @@ Verified with `--diag-hide`:
|
||||
- Reading `activationPolicy` is reliable but must happen on the main thread;
|
||||
keep all `NSWorkspace` access inside the model.
|
||||
- The menu bar tray needs Accessibility permission; do not add Screen Recording
|
||||
or Apple Events usage. `MenuBarHider` must always be restored on quit, but
|
||||
because a status item dies with its process, a crash cleans up on its own.
|
||||
or Apple Events usage. `MenuBarHider` is restored on quit, and because the
|
||||
cover windows belong to the process, a crash removes them automatically.
|
||||
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
|
||||
should never be committed.
|
||||
|
||||
@@ -23,25 +23,24 @@ permission (System Settings → Privacy & Security → Accessibility). The panel
|
||||
prompts for it; without it the Menu Bar section is empty and everything else
|
||||
still works.
|
||||
|
||||
## Hiding menu bar items (best-effort)
|
||||
## Hiding menu bar items
|
||||
|
||||
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.
|
||||
macOS has no public API to move another app's status item. Instead of moving
|
||||
them, the **Hide items from the menu bar** toggle (on by default, remembered)
|
||||
draws a frosted **cover** window over the other apps' icons. They keep their real
|
||||
positions, so tapping a tile in the panel opens that app's menu in the right
|
||||
place with **no flicker**, and the cover leaves a gap for the hamburger so it is
|
||||
never hidden.
|
||||
|
||||
What it can and cannot hide:
|
||||
What it does and does not cover:
|
||||
|
||||
- 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.)
|
||||
- Newly launched apps are placed to the left of the spacer, so they are hidden
|
||||
automatically.
|
||||
- Covers **other third-party apps' icons** (cmux, Kapture, WireGuard, input
|
||||
source, Now Playing, …).
|
||||
- Keeps **Wi-Fi, Spotlight, Clock, Battery and Control Center** visible — macOS
|
||||
reserves that far-right area, so those icons stay put.
|
||||
- The cover matches the menu bar's frosted look only approximately; over an
|
||||
unusual wallpaper the covered strip may be slightly visible.
|
||||
- Turning the toggle off removes the cover immediately; quitting does too.
|
||||
|
||||
## Install with Homebrew
|
||||
|
||||
|
||||
@@ -43,8 +43,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
AppListModel.shared.start()
|
||||
MenuBarItemManager.shared.start()
|
||||
MenuBarHider.shared.prepare()
|
||||
// Create the hamburger before the cover so its own icon is never covered.
|
||||
setupStatusItem()
|
||||
MenuBarItemManager.shared.refresh()
|
||||
|
||||
if CommandLine.arguments.contains("--diag-press") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
if let item = MenuBarItemManager.enumerate().first(where: {
|
||||
$0.title.lowercased().contains("wireguard")
|
||||
}) {
|
||||
NSLog("diag-press: pressing \(item.appName)")
|
||||
_ = AXUIElementPerformAction(item.element, kAXPressAction as CFString)
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
|
||||
dumpWindows()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if CommandLine.arguments.contains("--diag-cover") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||
dumpWindows()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if CommandLine.arguments.contains("--diag") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
|
||||
@@ -8,7 +8,9 @@ struct MenuBarItem: Identifiable, Equatable {
|
||||
let pid: pid_t
|
||||
let appName: String
|
||||
let title: String
|
||||
let appIcon: NSImage
|
||||
let icon: NSImage
|
||||
/// Frame in Accessibility coordinates (origin top-left of the main screen).
|
||||
let frame: CGRect?
|
||||
let element: AXUIElement
|
||||
|
||||
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
||||
@@ -52,9 +54,15 @@ final class MenuBarItemManager: ObservableObject {
|
||||
authorized = AXIsProcessTrusted()
|
||||
guard authorized else {
|
||||
items = []
|
||||
MenuBarHider.shared.apply(items: [], avoid: nil)
|
||||
return
|
||||
}
|
||||
items = Self.enumerate()
|
||||
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||||
let all = Self.enumerate(includeOwn: true)
|
||||
items = all.filter { $0.pid != ownPID }
|
||||
// Our own hamburger must never be covered, even if it lands inside a run.
|
||||
let avoid = all.first { $0.pid == ownPID }?.frame
|
||||
MenuBarHider.shared.apply(items: items, avoid: avoid)
|
||||
}
|
||||
|
||||
nonisolated static func enumerate(includeOwn: Bool = false) -> [MenuBarItem] {
|
||||
@@ -68,17 +76,39 @@ 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() {
|
||||
let position = pointAttribute(child, kAXPositionAttribute)
|
||||
// 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, 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 }
|
||||
|
||||
let size = sizeAttribute(child, kAXSizeAttribute)
|
||||
let frame: CGRect?
|
||||
if let position, let size {
|
||||
frame = CGRect(origin: position, size: size)
|
||||
} else {
|
||||
frame = nil
|
||||
}
|
||||
|
||||
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),
|
||||
frame: frame,
|
||||
element: child
|
||||
))
|
||||
}
|
||||
@@ -91,16 +121,66 @@ 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 at its own
|
||||
/// on-screen position. With the cover approach the item never moves, so no
|
||||
/// reveal is needed.
|
||||
func press(_ item: MenuBarItem) {
|
||||
_ = performPress(item)
|
||||
}
|
||||
|
||||
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? {
|
||||
@@ -136,59 +216,54 @@ final class MenuBarItemManager: ObservableObject {
|
||||
guard AXValueGetValue(value as! AXValue, .cgPoint, &point) else { return nil }
|
||||
return point
|
||||
}
|
||||
|
||||
nonisolated static func sizeAttribute(_ element: AXUIElement, _ attribute: String) -> CGSize? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
|
||||
let value,
|
||||
CFGetTypeID(value) == AXValueGetTypeID() else { return nil }
|
||||
var size = CGSize.zero
|
||||
guard AXValueGetValue(value as! AXValue, .cgSize, &size) else { return nil }
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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.
|
||||
/// Menu bar item hiding via a "cover": the icons stay exactly where macOS put
|
||||
/// them and are hidden by our own frosted window drawn over them. Because they
|
||||
/// never move, opening one's menu needs no reveal. On by default; the choice is
|
||||
/// persisted. Always restored on quit.
|
||||
@MainActor
|
||||
final class MenuBarHider: ObservableObject {
|
||||
static let shared = MenuBarHider()
|
||||
|
||||
/// 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 }
|
||||
apply(enabled)
|
||||
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
|
||||
MenuBarItemManager.shared.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var spacer: NSStatusItem?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
let item = NSStatusBar.system.statusItem(withLength: 0)
|
||||
item.autosaveName = name
|
||||
item.button?.isHidden = true
|
||||
spacer = item
|
||||
private init() {
|
||||
// Hide by default: the menu bar then shows only SimpleTopBar's hamburger
|
||||
// plus the Apple items macOS will not let us cover.
|
||||
enabled = UserDefaults.standard.object(forKey: Self.defaultsKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
private func apply(_ hiding: Bool) {
|
||||
prepare()
|
||||
spacer?.length = hiding ? Self.hiddenLength : 0
|
||||
isHiding = hiding
|
||||
/// Recompute the cover windows for the current menu bar items. `avoid` is
|
||||
/// our own hamburger's frame, which the cover must never hide.
|
||||
func apply(items: [MenuBarItem], avoid: CGRect?) {
|
||||
isHiding = enabled
|
||||
MenuBarCover.shared.update(enabled: enabled, items: items, avoid: avoid)
|
||||
}
|
||||
|
||||
/// Put the menu bar back the way we found it. Called on quit.
|
||||
/// Remove the cover. Called on quit. Leaves the user's preference intact.
|
||||
func restore() {
|
||||
spacer?.length = 0
|
||||
isHiding = false
|
||||
enabled = false
|
||||
MenuBarCover.shared.update(enabled: false, items: [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import AppKit
|
||||
|
||||
/// Hides other apps' menu bar icons by drawing a frosted window ("cover") over
|
||||
/// them, instead of moving them off-screen. The icons keep their real positions,
|
||||
/// so their menus open in the right place with no reveal, and a target item's
|
||||
/// real icon can be left visible by cutting a hole in the cover.
|
||||
@MainActor
|
||||
final class MenuBarCover {
|
||||
static let shared = MenuBarCover()
|
||||
|
||||
/// Just above the status items (level 25) but well below menus (~101).
|
||||
private static let windowLevel = NSWindow.Level(rawValue: 26)
|
||||
|
||||
private var windows: [NSWindow] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
func update(enabled: Bool, items: [MenuBarItem], avoid: CGRect? = nil) {
|
||||
removeAllWindows()
|
||||
guard enabled else { return }
|
||||
|
||||
let frames = items.compactMap { item -> CGRect? in
|
||||
guard Self.shouldHide(item), let frame = item.frame else { return nil }
|
||||
return frame
|
||||
}
|
||||
var runs = Self.runs(from: frames)
|
||||
if let avoid {
|
||||
runs = Self.subtracting(avoid, from: runs)
|
||||
}
|
||||
let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
|
||||
for run in runs {
|
||||
let frame = Self.windowFrame(for: run, primaryHeight: primaryHeight)
|
||||
windows.append(makeWindow(frame: frame))
|
||||
}
|
||||
}
|
||||
|
||||
/// Which items the cover hides. Everything is covered except the Apple
|
||||
/// items the user asked to keep visible.
|
||||
nonisolated static func shouldHide(_ item: MenuBarItem) -> Bool {
|
||||
let text = item.title.lowercased()
|
||||
.replacingOccurrences(of: "\u{2010}", with: "-")
|
||||
.replacingOccurrences(of: "\u{2011}", with: "-")
|
||||
let keep = ["wi-fi", "wifi", "battery", "clock", "control center", "search", "spotlight"]
|
||||
return !keep.contains(where: { text.contains($0) })
|
||||
}
|
||||
|
||||
/// Merge overlapping or near frames (in Accessibility coordinates) into
|
||||
/// contiguous runs, so adjacent icons get one cover window rather than many.
|
||||
nonisolated static func runs(from frames: [CGRect], gap: CGFloat = 6) -> [CGRect] {
|
||||
let sorted = frames.sorted { $0.minX < $1.minX }
|
||||
var result: [CGRect] = []
|
||||
for frame in sorted {
|
||||
if let last = result.last, frame.minX <= last.maxX + gap {
|
||||
result[result.count - 1] = last.union(frame)
|
||||
} else {
|
||||
result.append(frame)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Carve `avoid` out of the runs so the cover never hides our own icon.
|
||||
nonisolated static func subtracting(_ avoid: CGRect, from runs: [CGRect]) -> [CGRect] {
|
||||
var result: [CGRect] = []
|
||||
for run in runs {
|
||||
guard run.intersects(avoid) else {
|
||||
result.append(run)
|
||||
continue
|
||||
}
|
||||
if avoid.minX > run.minX {
|
||||
result.append(CGRect(x: run.minX, y: run.minY, width: avoid.minX - run.minX, height: run.height))
|
||||
}
|
||||
if avoid.maxX < run.maxX {
|
||||
result.append(CGRect(x: avoid.maxX, y: run.minY, width: run.maxX - avoid.maxX, height: run.height))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Accessibility coordinates use a top-left origin; NSWindow uses a
|
||||
/// bottom-left one, so flip Y against the primary screen's height.
|
||||
nonisolated static func windowFrame(for axFrame: CGRect, primaryHeight: CGFloat) -> CGRect {
|
||||
CGRect(
|
||||
x: axFrame.minX,
|
||||
y: primaryHeight - axFrame.maxY,
|
||||
width: axFrame.width,
|
||||
height: axFrame.height
|
||||
)
|
||||
}
|
||||
|
||||
private func makeWindow(frame: CGRect) -> NSWindow {
|
||||
let window = NSWindow(
|
||||
contentRect: frame,
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.level = Self.windowLevel
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = false
|
||||
// Swallow clicks so the covered icons cannot be clicked through.
|
||||
window.ignoresMouseEvents = false
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
|
||||
window.animationBehavior = .none
|
||||
|
||||
let effect = NSVisualEffectView(frame: CGRect(origin: .zero, size: frame.size))
|
||||
effect.material = .menu
|
||||
effect.blendingMode = .behindWindow
|
||||
effect.state = .active
|
||||
effect.autoresizingMask = [.width, .height]
|
||||
window.contentView = effect
|
||||
|
||||
window.orderFrontRegardless()
|
||||
return window
|
||||
}
|
||||
|
||||
private func removeAllWindows() {
|
||||
for window in windows {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
windows.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -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,41 @@ 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(), frame: nil, element: element)
|
||||
let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), frame: nil, element: element)
|
||||
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: NSImage(), frame: nil, 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")
|
||||
|
||||
// Cover geometry: adjacent icons merge into one run, far ones stay separate.
|
||||
let merged = MenuBarCover.runs(from: [CGRect(x: 0, y: 0, width: 10, height: 20),
|
||||
CGRect(x: 12, y: 0, width: 10, height: 20)])
|
||||
check(merged == [CGRect(x: 0, y: 0, width: 22, height: 20)], "adjacent frames merge into one cover")
|
||||
let split = MenuBarCover.runs(from: [CGRect(x: 0, y: 0, width: 10, height: 20),
|
||||
CGRect(x: 100, y: 0, width: 10, height: 20)])
|
||||
check(split.count == 2, "distant frames stay separate covers")
|
||||
|
||||
// Cover hides third-party apps but keeps the Apple items.
|
||||
let hiddenApp = MenuBarItem(id: "1-0", pid: 1, appName: "cmux", title: "cmux", icon: NSImage(), frame: nil, element: element)
|
||||
let keptWiFi = MenuBarItem(id: "2-0", pid: 2, appName: "Control Center", title: "Wi\u{2011}Fi, connected", icon: NSImage(), frame: nil, element: element)
|
||||
let keptBattery = MenuBarItem(id: "2-1", pid: 2, appName: "Control Center", title: "Battery", icon: NSImage(), frame: nil, element: element)
|
||||
let hiddenPlaying = MenuBarItem(id: "2-2", pid: 2, appName: "Control Center", title: "Now Playing", icon: NSImage(), frame: nil, element: element)
|
||||
check(MenuBarCover.shouldHide(hiddenApp), "third-party apps are covered")
|
||||
check(!MenuBarCover.shouldHide(keptWiFi), "wi-fi stays visible")
|
||||
check(!MenuBarCover.shouldHide(keptBattery), "battery stays visible")
|
||||
check(MenuBarCover.shouldHide(hiddenPlaying), "now playing is covered")
|
||||
|
||||
let converted = MenuBarCover.windowFrame(for: CGRect(x: 10, y: 0, width: 30, height: 24), primaryHeight: 1000)
|
||||
check(converted == CGRect(x: 10, y: 976, width: 30, height: 24), "AX frame flips Y for NSWindow")
|
||||
|
||||
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