diff --git a/Hidden Bar.xcodeproj/project.pbxproj b/Hidden Bar.xcodeproj/project.pbxproj index 6f9aa2f..8b7a339 100644 --- a/Hidden Bar.xcodeproj/project.pbxproj +++ b/Hidden Bar.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 4D8C4B09D62D4884B34E1A14 /* NotchOverflowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE73DB4E0664488997EEB6DE /* NotchOverflowController.swift */; }; 00117C4426600671005E517C /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00117C4326600671005E517C /* Assets.swift */; }; 00137CDF24A63DB1004AC855 /* Notification.Name+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00137CDE24A63DB1004AC855 /* Notification.Name+Extension.swift */; }; 0842CDFB23A9FDD000D14BD4 /* GlobalKeybindingPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0842CDFA23A9FDD000D14BD4 /* GlobalKeybindingPreferences.swift */; }; @@ -57,6 +58,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BE73DB4E0664488997EEB6DE /* NotchOverflowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOverflowController.swift; sourceTree = ""; }; 00117C4326600671005E517C /* Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; 00137CDE24A63DB1004AC855 /* Notification.Name+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification.Name+Extension.swift"; sourceTree = ""; }; 0842CDFA23A9FDD000D14BD4 /* GlobalKeybindingPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalKeybindingPreferences.swift; sourceTree = ""; }; @@ -152,12 +154,21 @@ isa = PBXGroup; children = ( 08C20FDC23AABC440035D978 /* About */, + 905962CD99D24D0C893AFFA7 /* NotchOverflow */, 0842CDFE23A9FF7C00D14BD4 /* Preferences */, 0842CDFD23A9FF7500D14BD4 /* StatusBar */, ); path = Features; sourceTree = ""; }; + 905962CD99D24D0C893AFFA7 /* NotchOverflow */ = { + isa = PBXGroup; + children = ( + BE73DB4E0664488997EEB6DE /* NotchOverflowController.swift */, + ); + path = NotchOverflow; + sourceTree = ""; + }; 0842CDFD23A9FF7500D14BD4 /* StatusBar */ = { isa = PBXGroup; children = ( @@ -422,6 +433,7 @@ 08C20FE223AB452C0035D978 /* AboutViewController.swift in Sources */, 00137CDF24A63DB1004AC855 /* Notification.Name+Extension.swift in Sources */, 08A5F86423AA09F300981CA5 /* UserDefault+Extension.swift in Sources */, + 4D8C4B09D62D4884B34E1A14 /* NotchOverflowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/hidden/AppDelegate.swift b/hidden/AppDelegate.swift index 4f5f21b..256f842 100644 --- a/hidden/AppDelegate.swift +++ b/hidden/AppDelegate.swift @@ -7,6 +7,7 @@ // import AppKit +import Carbon import HotKey @NSApplicationMain @@ -14,23 +15,38 @@ import HotKey class AppDelegate: NSObject, NSApplicationDelegate{ var statusBarController = StatusBarController() - + var hotKey: HotKey? { didSet { guard let hotKey = hotKey else { return } - + hotKey.keyDownHandler = { [weak self] in self?.statusBarController.expandCollapseIfNeeded() } } } + // Notch overflow hotkey: Cmd+Shift+B + var notchOverflowHotKey: HotKey? + func applicationDidFinishLaunching(_ aNotification: Notification) { setupAutoStartApp() registerDefaultValues() setupHotKey() openPreferencesIfNeeded() detectLTRLang() + statusBarController.setupNotchOverflow() + setupNotchOverflowHotKey() + } + + func setupNotchOverflowHotKey() { + guard NotchOverflowController.hasNotch else { return } + // Cmd+Shift+B (keyCode 11 = B) + let carbonMods = UInt32(cmdKey | shiftKey) + notchOverflowHotKey = HotKey(keyCombo: KeyCombo(carbonKeyCode: 11, carbonModifiers: carbonMods)) + notchOverflowHotKey?.keyDownHandler = { [weak self] in + self?.statusBarController.notchOverflowController.triggerOverflow() + } } func openPreferencesIfNeeded() { @@ -50,7 +66,8 @@ class AppDelegate: NSObject, NSApplicationDelegate{ UserDefaults.Key.isAutoHide: true, UserDefaults.Key.numberOfSecondForAutoHide: 10.0, UserDefaults.Key.areSeparatorsHidden: false, - UserDefaults.Key.alwaysHiddenSectionEnabled: false + UserDefaults.Key.alwaysHiddenSectionEnabled: false, + UserDefaults.Key.notchOverflowEnabled: true ]) } diff --git a/hidden/Common/Preferences.swift b/hidden/Common/Preferences.swift index d69adb1..a38b4bd 100644 --- a/hidden/Common/Preferences.swift +++ b/hidden/Common/Preferences.swift @@ -99,11 +99,20 @@ enum Preferences { get { UserDefaults.standard.bool(forKey: UserDefaults.Key.useFullStatusBarOnExpandEnabled) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.useFullStatusBarOnExpandEnabled) } } - - + + static var notchOverflowEnabled: Bool { + get { + UserDefaults.standard.bool(forKey: UserDefaults.Key.notchOverflowEnabled) + } + + set { + UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.notchOverflowEnabled) + NotificationCenter.default.post(Notification(name: .notchOverflowToggle)) + } + } } diff --git a/hidden/Extensions/Notification.Name+Extension.swift b/hidden/Extensions/Notification.Name+Extension.swift index 90dbcd5..cea0743 100644 --- a/hidden/Extensions/Notification.Name+Extension.swift +++ b/hidden/Extensions/Notification.Name+Extension.swift @@ -12,4 +12,5 @@ extension Notification.Name { static let prefsChanged = Notification.Name("prefsChanged") static let alwayHideToggle = Notification.Name("alwayHideToggle") + static let notchOverflowToggle = Notification.Name("notchOverflowToggle") } diff --git a/hidden/Extensions/UserDefault+Extension.swift b/hidden/Extensions/UserDefault+Extension.swift index a870fab..4729e07 100644 --- a/hidden/Extensions/UserDefault+Extension.swift +++ b/hidden/Extensions/UserDefault+Extension.swift @@ -18,6 +18,7 @@ extension UserDefaults { static let areSeparatorsHidden = "areSeparatorsHidden" static let alwaysHiddenSectionEnabled = "alwaysHiddenSectionEnabled" static let useFullStatusBarOnExpandEnabled = "useFullStatusBarOnExpandEnabled" + static let notchOverflowEnabled = "notchOverflowEnabled" } open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { diff --git a/hidden/Features/NotchOverflow/NotchOverflowController.swift b/hidden/Features/NotchOverflow/NotchOverflowController.swift new file mode 100644 index 0000000..3275cbc --- /dev/null +++ b/hidden/Features/NotchOverflow/NotchOverflowController.swift @@ -0,0 +1,279 @@ +// +// NotchOverflowController.swift +// Hidden Bar +// +// Created by Nadir on 2026/03/30. +// Copyright © 2026 Dwarves Foundation. All rights reserved. +// + +import AppKit +import ApplicationServices + +// MARK: - Menu Bar Extra Info + +struct MenuBarExtraInfo { + let element: AXUIElement + let appName: String + let appIcon: NSImage? + let title: String? + let pid: pid_t + let position: CGPoint + let size: CGSize +} + +// MARK: - NotchOverflowController + +class NotchOverflowController: NSObject { + + // MARK: - Properties + + /// Right edge of the notch in screen X coordinates + private var notchRightEdge: CGFloat { + guard let screen = NSScreen.main else { return 972 } + let notchWidth = screen.frame.width / 8 + return screen.frame.width / 2 + notchWidth / 2 + } + + // MARK: - Notch Detection + + static var hasNotch: Bool { + if #available(macOS 12.0, *) { + guard let screen = NSScreen.main else { return false } + return screen.safeAreaInsets.top > 0 + } + return false + } + + // MARK: - Setup + + func setup() { + // No-op; hotkey is set up in AppDelegate + } + + func teardown() { + // No-op + } + + // MARK: - Public: called from hotkey handler + func triggerOverflow() { + showOverflowMenuAtCursor() + } + + /// Show overflow menu anchored to a status item (called from StatusBarController) + func showOverflowMenuFromSeparator(near item: NSStatusItem) { + guard ensureAccessibility() else { return } + let menu = buildOverflowMenu() + item.menu = menu + item.button?.performClick(nil) + DispatchQueue.main.async { + item.menu = nil + } + } + + // MARK: - Show Menu at Cursor + + private func showOverflowMenuAtCursor() { + guard ensureAccessibility() else { return } + + let menu = buildOverflowMenu() + let mouseLocation = NSEvent.mouseLocation + + // Create a temporary invisible window at mouse location to anchor the menu + let tmpWindow = NSWindow( + contentRect: NSRect(x: mouseLocation.x - 1, y: mouseLocation.y - 1, width: 2, height: 2), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + tmpWindow.level = .popUpMenu + tmpWindow.backgroundColor = .clear + tmpWindow.isOpaque = false + tmpWindow.orderFrontRegardless() + + menu.popUp(positioning: nil, at: NSPoint(x: 1, y: 1), in: tmpWindow.contentView) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + tmpWindow.orderOut(nil) + } + } + + // MARK: - Menu Building + + private func buildOverflowMenu() -> NSMenu { + let allExtras = getAllMenuBarExtras() + + let rightEdge = notchRightEdge + let hiddenExtras = allExtras.filter { + $0.size.width > 0 && $0.size.height > 0 && $0.position.x < rightEdge + } + let visibleExtras = allExtras.filter { + $0.size.width > 0 && $0.size.height > 0 && $0.position.x >= rightEdge + } + + let menu = NSMenu() + menu.autoenablesItems = false + + // Title + let titleItem = NSMenuItem(title: "Notch Overflow \u{2318}\u{21E7}B", action: nil, keyEquivalent: "") + titleItem.isEnabled = false + if #available(macOS 10.14, *) { + titleItem.attributedTitle = NSAttributedString( + string: titleItem.title, + attributes: [ + .font: NSFont.systemFont(ofSize: 12, weight: .bold), + .foregroundColor: NSColor.secondaryLabelColor + ]) + } + menu.addItem(titleItem) + menu.addItem(NSMenuItem.separator()) + + // Hidden section + if !hiddenExtras.isEmpty { + let header = NSMenuItem( + title: "\u{26A0} Hidden Behind Notch (\(hiddenExtras.count))", + action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + menu.addItem(NSMenuItem.separator()) + + for info in hiddenExtras.sorted(by: { $0.position.x > $1.position.x }) { + menu.addItem(makeMenuItem(for: info, hidden: true)) + } + } + + // Visible section + if !visibleExtras.isEmpty { + if !hiddenExtras.isEmpty { menu.addItem(NSMenuItem.separator()) } + let header = NSMenuItem( + title: "Visible (\(visibleExtras.count))", + action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + menu.addItem(NSMenuItem.separator()) + + for info in visibleExtras.sorted(by: { $0.position.x > $1.position.x }) { + menu.addItem(makeMenuItem(for: info, hidden: false)) + } + } + + // Empty state + if hiddenExtras.isEmpty && visibleExtras.isEmpty { + let emptyItem = NSMenuItem( + title: "No items found (grant Accessibility permission)", + action: nil, keyEquivalent: "") + emptyItem.isEnabled = false + menu.addItem(emptyItem) + } + + return menu + } + + private func makeMenuItem(for info: MenuBarExtraInfo, hidden: Bool = false) -> NSMenuItem { + let item = NSMenuItem() + let displayTitle = (info.title?.isEmpty == false) ? info.title! : info.appName + + if hidden { + if #available(macOS 10.14, *) { + item.attributedTitle = NSAttributedString( + string: displayTitle, + attributes: [ + .font: NSFont.systemFont(ofSize: 13), + .foregroundColor: NSColor.systemOrange + ]) + } else { + item.title = displayTitle + } + } else { + item.title = displayTitle + } + + if let icon = info.appIcon { + item.image = hidden ? icon.resizedForMenu(tinted: true) : icon.resizedForMenu() + } + item.representedObject = info + item.target = self + item.action = #selector(activateItem(_:)) + return item + } + + @objc private func activateItem(_ sender: NSMenuItem) { + guard let info = sender.representedObject as? MenuBarExtraInfo else { return } + AXUIElementPerformAction(info.element, kAXPressAction as CFString) + } + + // MARK: - Accessibility + + private func ensureAccessibility() -> Bool { + if AXIsProcessTrusted() { return true } + let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + AXIsProcessTrustedWithOptions(opts) + return false + } + + // MARK: - AX Enumeration + + private func getAllMenuBarExtras() -> [MenuBarExtraInfo] { + var results: [MenuBarExtraInfo] = [] + let myPID = ProcessInfo.processInfo.processIdentifier + + for app in NSWorkspace.shared.runningApplications { + if app.processIdentifier == myPID { continue } + + let axApp = AXUIElementCreateApplication(app.processIdentifier) + var extrasRef: AnyObject? + let err = AXUIElementCopyAttributeValue( + axApp, "AXExtrasMenuBar" as CFString, &extrasRef) + guard err == .success else { continue } + + var childrenRef: AnyObject? + let childErr = AXUIElementCopyAttributeValue( + extrasRef as! AXUIElement, + kAXChildrenAttribute as CFString, &childrenRef) + guard childErr == .success, + let children = childrenRef as? [AXUIElement] + else { continue } + + for child in children { + var titleRef: AnyObject? + AXUIElementCopyAttributeValue(child, kAXTitleAttribute as CFString, &titleRef) + + var posRef: AnyObject? + AXUIElementCopyAttributeValue(child, kAXPositionAttribute as CFString, &posRef) + var pos = CGPoint.zero + if let pv = posRef { AXValueGetValue(pv as! AXValue, .cgPoint, &pos) } + + var sizeRef: AnyObject? + AXUIElementCopyAttributeValue(child, kAXSizeAttribute as CFString, &sizeRef) + var size = CGSize.zero + if let sv = sizeRef { AXValueGetValue(sv as! AXValue, .cgSize, &size) } + + results.append(MenuBarExtraInfo( + element: child, + appName: app.localizedName ?? "Unknown", + appIcon: app.icon, + title: titleRef as? String, + pid: app.processIdentifier, + position: pos, + size: size + )) + } + } + return results + } + +} + +// MARK: - NSImage Extension + +private extension NSImage { + func resizedForMenu(tinted: Bool = false) -> NSImage { + let target = NSSize(width: 18, height: 18) + let img = NSImage(size: target) + img.lockFocus() + self.draw(in: NSRect(origin: .zero, size: target), + from: NSRect(origin: .zero, size: self.size), + operation: .sourceOver, fraction: tinted ? 0.5 : 1.0) + img.unlockFocus() + return img + } +} diff --git a/hidden/Features/Preferences/PreferencesViewController.swift b/hidden/Features/Preferences/PreferencesViewController.swift index 1703cd2..5af20eb 100644 --- a/hidden/Features/Preferences/PreferencesViewController.swift +++ b/hidden/Features/Preferences/PreferencesViewController.swift @@ -53,6 +53,7 @@ class PreferencesViewController: NSViewController { updateData() loadHotkey() createTutorialView() + addNotchOverflowSection() NotificationCenter.default.addObserver(self, selector: #selector(updateData), name: .prefsChanged, object: nil) } @@ -190,6 +191,33 @@ class PreferencesViewController: NSViewController { } } +//MARK: - Notch Overflow Section +extension PreferencesViewController { + + func addNotchOverflowSection() { + guard NotchOverflowController.hasNotch else { return } + + // Add compact info below the tutorial area, anchored to statusBarStackView + let infoLabel = NSTextField(labelWithString: + "Notch Overflow: \u{2318}\u{21E7}B or right-click \u{2039} to access hidden icons") + infoLabel.font = NSFont.systemFont(ofSize: 10.5) + if #available(macOS 10.14, *) { + infoLabel.textColor = .secondaryLabelColor + } + infoLabel.alignment = .center + infoLabel.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(infoLabel) + + // Position centered, between tutorial text and Settings divider + // statusBarStackView is near the top; we go well below it + NSLayoutConstraint.activate([ + infoLabel.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + infoLabel.topAnchor.constraint(equalTo: statusBarStackView.bottomAnchor, constant: 78) + ]) + } + +} + //MARK: - Show tutorial extension PreferencesViewController { diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index 1d17718..79c7abb 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -9,15 +9,18 @@ import AppKit class StatusBarController { - + //MARK: - Variables private var timer:Timer? = nil - + //MARK: - BarItems - + private let btnExpandCollapse = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let btnSeparate = NSStatusBar.system.statusItem(withLength: 1) private var btnAlwaysHidden:NSStatusItem? = nil + + //MARK: - Notch Overflow + private(set) var notchOverflowController = NotchOverflowController() private var btnHiddenLength: CGFloat = 20 private var btnHiddenCollapseLength: CGFloat = 2000 @@ -73,6 +76,21 @@ class StatusBarController { if Preferences.areSeparatorsHidden {hideSeparators()} autoCollapseIfNeeded() + + NotificationCenter.default.addObserver(self, selector: #selector(handleNotchOverflowToggle), name: .notchOverflowToggle, object: nil) + } + + /// Called from AppDelegate after defaults are registered + func setupNotchOverflow() { + notchOverflowController.setup() + } + + @objc private func handleNotchOverflowToggle() { + if Preferences.notchOverflowEnabled { + notchOverflowController.setup() + } else { + notchOverflowController.teardown() + } } deinit { @@ -115,16 +133,53 @@ class StatusBarController { @objc func btnExpandCollapsePressed(sender: NSStatusBarButton) { if let event = NSApp.currentEvent { - + let isOptionKeyPressed = event.modifierFlags.contains(NSEvent.ModifierFlags.option) - - if event.type == NSEvent.EventType.leftMouseUp && !isOptionKeyPressed{ + + if event.type == NSEvent.EventType.leftMouseUp && !isOptionKeyPressed { self.expandCollapseIfNeeded() } else { - self.showHideSeparatorsAndAlwayHideArea() + // Right-click or Opt+click: show context menu + self.showExpandButtonMenu() } } } + + private func showExpandButtonMenu() { + let menu = NSMenu() + + // Notch overflow (only on notch Macs) + if NotchOverflowController.hasNotch { + let overflowItem = NSMenuItem( + title: "Show Notch Items (\u{2318}\u{21E7}B)", + action: #selector(showNotchOverflow), + keyEquivalent: "") + overflowItem.target = self + menu.addItem(overflowItem) + menu.addItem(NSMenuItem.separator()) + } + + // Toggle separators (existing functionality) + let sepTitle = Preferences.areSeparatorsHidden + ? "Show Separators" + : "Hide Separators" + let toggleSep = NSMenuItem( + title: sepTitle, + action: #selector(toggleSeparatorsAction), + keyEquivalent: "") + toggleSep.target = self + menu.addItem(toggleSep) + + btnExpandCollapse.menu = menu + btnExpandCollapse.button?.performClick(nil) + DispatchQueue.main.async { [weak self] in + self?.btnExpandCollapse.menu = nil + } + } + + @objc private func toggleSeparatorsAction() { + self.showHideSeparatorsAndAlwayHideArea() + } func showHideSeparatorsAndAlwayHideArea() { Preferences.areSeparatorsHidden ? self.showSeparators() : self.hideSeparators() @@ -212,11 +267,19 @@ class StatusBarController { private func getContextMenu() -> NSMenu { let menu = NSMenu() - + + // Notch overflow menu item (only on notch Macs) + if NotchOverflowController.hasNotch { + let overflowItem = NSMenuItem(title: "Show Notch Items (\u{2318}\u{21E7}B)", action: #selector(showNotchOverflow), keyEquivalent: "") + overflowItem.target = self + menu.addItem(overflowItem) + menu.addItem(NSMenuItem.separator()) + } + let prefItem = NSMenuItem(title: "Preferences...".localized, action: #selector(openPreferenceViewControllerIfNeeded), keyEquivalent: "P") prefItem.target = self menu.addItem(prefItem) - + let toggleAutoHideItem = NSMenuItem(title: "Toggle Auto Collapse".localized, action: #selector(toggleAutoHide), keyEquivalent: "t") toggleAutoHideItem.target = self toggleAutoHideItem.tag = 1 @@ -246,6 +309,10 @@ class StatusBarController { @objc func openPreferenceViewControllerIfNeeded() { Util.showPrefWindow() } + + @objc func showNotchOverflow() { + notchOverflowController.showOverflowMenuFromSeparator(near: btnExpandCollapse) + } @objc func toggleAutoHide() { Preferences.isAutoHide.toggle()