From 02721854e30b3b0a31658ae31bde06681b284518 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sun, 31 May 2026 00:01:43 -0700 Subject: [PATCH 1/7] fix: show hidden items in separate bar Add an opt-in preferences setting that displays hidden menu bar items in a dedicated panel below the menu bar. The implementation captures individual menu bar item windows, collapses the inline section again, masks the capture flash, and forwards clicks back to the source item when Accessibility permission is available. Document the Screen Recording and Accessibility permission requirements while preserving the existing inline behavior by default. --- Hidden Bar.xcodeproj/project.pbxproj | 4 + README.md | 5 + hidden/AppDelegate.swift | 4 +- hidden/Base.lproj/Main.storyboard | 17 +- hidden/Common/Preferences.swift | 58 ++- hidden/Extensions/UserDefault+Extension.swift | 1 + .../PreferencesViewController.swift | 124 ++--- .../HiddenItemsBarPanelController.swift | 241 ++++++++++ .../StatusBar/StatusBarController.swift | 439 +++++++++++++++--- hidden/en.lproj/Localizable.strings | 2 + 10 files changed, 756 insertions(+), 139 deletions(-) create mode 100644 hidden/Features/StatusBar/HiddenItemsBarPanelController.swift diff --git a/Hidden Bar.xcodeproj/project.pbxproj b/Hidden Bar.xcodeproj/project.pbxproj index 6f9aa2f..30cbbee 100644 --- a/Hidden Bar.xcodeproj/project.pbxproj +++ b/Hidden Bar.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 3CF14C82221FA49D0083D42B /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF14C81221FA49D0083D42B /* PreferencesWindowController.swift */; }; 55F3F1192608923B0054B881 /* Date+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F3F1182608923B0054B881 /* Date+Extension.swift */; }; 55F3F11D2608925A0054B881 /* StackView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F3F11C2608925A0054B881 /* StackView+Extension.swift */; }; + 6C0A10012DEBF00100A5E001 /* HiddenItemsBarPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0A10002DEBF00100A5E001 /* HiddenItemsBarPanelController.swift */; }; 9220587E2212A7C0008A8B03 /* LauncherApplication.app in Copy Files */ = {isa = PBXBuildFile; fileRef = 92C5054E21FEC03B0084719A /* LauncherApplication.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 929113F521F9D04100173149 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 929113F421F9D04100173149 /* AppDelegate.swift */; }; 929113F921F9D04200173149 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 929113F821F9D04200173149 /* Assets.xcassets */; }; @@ -83,6 +84,7 @@ 55D9B6BD2661DEE8007AF073 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 55F3F1182608923B0054B881 /* Date+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extension.swift"; sourceTree = ""; }; 55F3F11C2608925A0054B881 /* StackView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StackView+Extension.swift"; sourceTree = ""; }; + 6C0A10002DEBF00100A5E001 /* HiddenItemsBarPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenItemsBarPanelController.swift; sourceTree = ""; }; 9220587D22127050008A8B03 /* Hidden.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = Hidden.entitlements; path = Hidden/Hidden.entitlements; sourceTree = ""; }; 929113F121F9D04100173149 /* Hidden Bar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Hidden Bar.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 929113F421F9D04100173149 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -162,6 +164,7 @@ isa = PBXGroup; children = ( 92C97B9022018C1F0007559C /* StatusBarController.swift */, + 6C0A10002DEBF00100A5E001 /* HiddenItemsBarPanelController.swift */, ); path = StatusBar; sourceTree = ""; @@ -405,6 +408,7 @@ 08A5F85C23AA013100981CA5 /* SelectedSecond.swift in Sources */, 08A5F86123AA085B00981CA5 /* Preferences.swift in Sources */, 92C97B9122018C1F0007559C /* StatusBarController.swift in Sources */, + 6C0A10012DEBF00100A5E001 /* HiddenItemsBarPanelController.swift in Sources */, 55F3F1192608923B0054B881 /* Date+Extension.swift in Sources */, 92D2122221FEE06600C92FF4 /* LauncherApplication.app in Sources */, 08B9F32C2411883300AA0551 /* NSWindow+Extension.swift in Sources */, diff --git a/README.md b/README.md index 53eff3d..16ba6ce 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ brew install --cask hiddenbar * `⌘` + drag to move the Hidden icons around in the menu bar. * Click the Arrow icon to hide menu bar items. +* Enable "Show hidden items in separate bar" in Preferences to show + hidden menu bar items in a separate bar below the menu bar on the current + display. This mode captures the hidden menu bar item windows and forwards + clicks back to the original items, so macOS may ask for Screen Recording and + Accessibility permission.

diff --git a/hidden/AppDelegate.swift b/hidden/AppDelegate.swift index 4f5f21b..63758ab 100644 --- a/hidden/AppDelegate.swift +++ b/hidden/AppDelegate.swift @@ -50,7 +50,9 @@ 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.useFullStatusBarOnExpandEnabled: false, + UserDefaults.Key.showHiddenItemsInSeparateBar: false ]) } diff --git a/hidden/Base.lproj/Main.storyboard b/hidden/Base.lproj/Main.storyboard index 53b9127..21956d3 100644 --- a/hidden/Base.lproj/Main.storyboard +++ b/hidden/Base.lproj/Main.storyboard @@ -622,10 +622,10 @@ between sections to configure Hidden Bar. - + - + + @@ -762,6 +772,7 @@ between sections to configure Hidden Bar. + @@ -769,6 +780,7 @@ between sections to configure Hidden Bar. + @@ -863,6 +875,7 @@ between sections to configure Hidden Bar. + diff --git a/hidden/Common/Preferences.swift b/hidden/Common/Preferences.swift index d69adb1..4e946ef 100644 --- a/hidden/Common/Preferences.swift +++ b/hidden/Common/Preferences.swift @@ -9,101 +9,111 @@ import Foundation enum Preferences { - + static var globalKey: GlobalKeybindPreferences? { get { guard let data = UserDefaults.standard.value(forKey: UserDefaults.Key.globalKey) as? Data else { return nil } return try? JSONDecoder().decode(GlobalKeybindPreferences.self, from: data) } - + set { guard let data = try? JSONEncoder().encode(newValue) else { return } UserDefaults.standard.set(data, forKey: UserDefaults.Key.globalKey) - + NotificationCenter.default.post(Notification(name: .prefsChanged)) } } - + static var isAutoStart: Bool { get { return UserDefaults.standard.bool(forKey: UserDefaults.Key.isAutoStart) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.isAutoStart) - + Util.setUpAutoStart(isAutoStart: newValue) - + NotificationCenter.default.post(Notification(name: .prefsChanged)) } } - + static var numberOfSecondForAutoHide: Double { get { UserDefaults.standard.double(forKey: UserDefaults.Key.numberOfSecondForAutoHide) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.numberOfSecondForAutoHide) - + NotificationCenter.default.post(Notification(name: .prefsChanged)) } } - + static var isAutoHide: Bool { get { UserDefaults.standard.bool(forKey: UserDefaults.Key.isAutoHide) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.isAutoHide) - + NotificationCenter.default.post(Notification(name: .prefsChanged)) } } - + static var isShowPreference: Bool { get { UserDefaults.standard.bool(forKey: UserDefaults.Key.isShowPreference) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.isShowPreference) - + NotificationCenter.default.post(Notification(name: .prefsChanged)) } } - + static var areSeparatorsHidden: Bool { get { UserDefaults.standard.bool(forKey: UserDefaults.Key.areSeparatorsHidden) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.areSeparatorsHidden) } } - + static var alwaysHiddenSectionEnabled: Bool { get { UserDefaults.standard.bool(forKey: UserDefaults.Key.alwaysHiddenSectionEnabled) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.alwaysHiddenSectionEnabled) NotificationCenter.default.post(Notification(name: .alwayHideToggle)) } } - + static var useFullStatusBarOnExpandEnabled: Bool { get { UserDefaults.standard.bool(forKey: UserDefaults.Key.useFullStatusBarOnExpandEnabled) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.useFullStatusBarOnExpandEnabled) + NotificationCenter.default.post(Notification(name: .prefsChanged)) + } + } + + static var showHiddenItemsInSeparateBar: Bool { + get { + UserDefaults.standard.bool(forKey: UserDefaults.Key.showHiddenItemsInSeparateBar) + } + + set { + UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.showHiddenItemsInSeparateBar) + NotificationCenter.default.post(Notification(name: .prefsChanged)) } } - - } diff --git a/hidden/Extensions/UserDefault+Extension.swift b/hidden/Extensions/UserDefault+Extension.swift index a870fab..a86e5ab 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 showHiddenItemsInSeparateBar = "showHiddenItemsInSeparateBar" } open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { diff --git a/hidden/Features/Preferences/PreferencesViewController.swift b/hidden/Features/Preferences/PreferencesViewController.swift index 1703cd2..425615e 100644 --- a/hidden/Features/Preferences/PreferencesViewController.swift +++ b/hidden/Features/Preferences/PreferencesViewController.swift @@ -11,42 +11,43 @@ import Carbon import HotKey class PreferencesViewController: NSViewController { - - + + //MARK: - Outlets @IBOutlet weak var checkBoxKeepLastState: NSButton! @IBOutlet weak var textFieldTitle: NSTextField! @IBOutlet weak var imageViewTop: NSImageView! - + @IBOutlet weak var statusBarStackView: NSStackView! @IBOutlet weak var arrowPointToHiddenImage: NSImageView! @IBOutlet weak var arrowPointToAlwayHiddenImage: NSImageView! @IBOutlet weak var lblAlwayHidden: NSTextField! - - - + + + @IBOutlet weak var checkBoxAutoHide: NSButton! @IBOutlet weak var checkBoxKeepInDock: NSButton! @IBOutlet weak var checkBoxLogin: NSButton! @IBOutlet weak var checkBoxShowPreferences: NSButton! @IBOutlet weak var checkBoxShowAlwaysHiddenSection: NSButton! - + @IBOutlet weak var checkBoxUseFullStatusbar: NSButton! + @IBOutlet weak var checkBoxShowHiddenItemsInSeparateBar: NSButton! @IBOutlet weak var timePopup: NSPopUpButton! - + @IBOutlet weak var btnClear: NSButton! @IBOutlet weak var btnShortcut: NSButton! - + public var listening = false { didSet { let isHighlight = listening - + DispatchQueue.main.async { [weak self] in self?.btnShortcut.highlight(isHighlight) } } } - + //MARK: - VC Life cycle override func viewDidLoad() { super.viewDidLoad() @@ -55,26 +56,26 @@ class PreferencesViewController: NSViewController { createTutorialView() NotificationCenter.default.addObserver(self, selector: #selector(updateData), name: .prefsChanged, object: nil) } - + static func initWithStoryboard() -> PreferencesViewController { let vc = NSStoryboard(name:"Main", bundle: nil).instantiateController(withIdentifier: "prefVC") as! PreferencesViewController return vc } - + //MARK: - Actions @IBAction func loginCheckChanged(_ sender: NSButton) { Preferences.isAutoStart = sender.state == .on } - + @IBAction func autoHideCheckChanged(_ sender: NSButton) { Preferences.isAutoHide = sender.state == .on } - + @IBAction func showPreferencesChanged(_ sender: NSButton) { Preferences.isShowPreference = sender.state == .on } - - + + @IBAction func showAlwaysHiddenSectionChanged(_ sender: NSButton) { Preferences.alwaysHiddenSectionEnabled = sender.state == .on createTutorialView() @@ -82,21 +83,25 @@ class PreferencesViewController: NSViewController { @IBAction func useFullStatusBarOnExpandChanged(_ sender: NSButton) { Preferences.useFullStatusBarOnExpandEnabled = sender.state == .on } - - + + @IBAction func showHiddenItemsInSeparateBarChanged(_ sender: NSButton) { + Preferences.showHiddenItemsInSeparateBar = sender.state == .on + } + + @IBAction func timePopupDidSelected(_ sender: NSPopUpButton) { let selectedIndex = sender.indexOfSelectedItem if let selectedInSecond = SelectedSecond(rawValue: selectedIndex)?.toSeconds() { Preferences.numberOfSecondForAutoHide = selectedInSecond } } - + // When the set shortcut button is pressed start listening for the new shortcut @IBAction func register(_ sender: Any) { listening = true view.window?.makeFirstResponder(nil) } - + // If the shortcut is cleared, clear the UI and tell AppDelegate to stop listening to the previous keybind. @IBAction func unregister(_ sender: Any?) { let appDelegate = NSApplication.shared.delegate as! AppDelegate @@ -104,16 +109,16 @@ class PreferencesViewController: NSViewController { btnShortcut.title = "Set Shortcut".localized listening = false btnClear.isEnabled = false - + // Remove globalkey from userdefault Preferences.globalKey = nil } - + public func updateGlobalShortcut(_ event: NSEvent) { self.listening = false - + guard let characters = event.charactersIgnoringModifiers else {return} - + let newGlobalKeybind = GlobalKeybindPreferences( function: event.modifierFlags.contains(.function), control: event.modifierFlags.contains(.control), @@ -124,16 +129,16 @@ class PreferencesViewController: NSViewController { carbonFlags: event.modifierFlags.carbonFlags, characters: characters, keyCode: uint32(event.keyCode)) - + Preferences.globalKey = newGlobalKeybind - + updateKeybindButton(newGlobalKeybind) btnClear.isEnabled = true - + let appDelegate = NSApplication.shared.delegate as! AppDelegate appDelegate.hotKey = HotKey(keyCombo: KeyCombo(carbonKeyCode: UInt32(event.keyCode), carbonModifiers: event.modifierFlags.carbonFlags)) } - + public func updateModiferFlags(_ event: NSEvent) { let newGlobalKeybind = GlobalKeybindPreferences( function: event.modifierFlags.contains(.function), @@ -145,45 +150,46 @@ class PreferencesViewController: NSViewController { carbonFlags: 0, characters: nil, keyCode: uint32(event.keyCode)) - + updateModifierbindButton(newGlobalKeybind) - + } - + @objc private func updateData(){ checkBoxUseFullStatusbar.state = Preferences.useFullStatusBarOnExpandEnabled ? .on : .off checkBoxLogin.state = Preferences.isAutoStart ? .on : .off checkBoxAutoHide.state = Preferences.isAutoHide ? .on : .off checkBoxShowPreferences.state = Preferences.isShowPreference ? .on : .off checkBoxShowAlwaysHiddenSection.state = Preferences.alwaysHiddenSectionEnabled ? .on : .off + checkBoxShowHiddenItemsInSeparateBar.state = Preferences.showHiddenItemsInSeparateBar ? .on : .off timePopup.selectItem(at: SelectedSecond.secondToPossition(seconds: Preferences.numberOfSecondForAutoHide)) } - + private func loadHotkey() { if let globalKey = Preferences.globalKey { updateKeybindButton(globalKey) updateClearButton(globalKey) } } - + // Set the shortcut button to show the keys to press private func updateKeybindButton(_ globalKeybindPreference : GlobalKeybindPreferences) { btnShortcut.title = globalKeybindPreference.description - + if globalKeybindPreference.description.count <= 1 { unregister(nil) } } - + // Set the shortcut button to show the modifier to press private func updateModifierbindButton(_ globalKeybindPreference : GlobalKeybindPreferences) { btnShortcut.title = globalKeybindPreference.description - + if globalKeybindPreference.description.isEmpty { unregister(nil) } } - + // If a keybind is set, allow users to clear it by enabling the clear button. private func updateClearButton(_ globalKeybindPreference : GlobalKeybindPreferences?) { btnClear.isEnabled = globalKeybindPreference != nil @@ -192,7 +198,7 @@ class PreferencesViewController: NSViewController { //MARK: - Show tutorial extension PreferencesViewController { - + func createTutorialView() { if Preferences.alwaysHiddenSectionEnabled { alwayHideStatusBar() @@ -200,26 +206,26 @@ extension PreferencesViewController { hideStatusBar() } } - + func hideStatusBar() { lblAlwayHidden.isHidden = true arrowPointToAlwayHiddenImage.isHidden = true statusBarStackView.removeAllSubViews() let imageWidth: CGFloat = 16 - - + + let images = ["ico_1","ico_2","ico_3","seprated", "ico_collapse","ico_4","ico_5","ico_6","ico_7"].map { imageName in NSImageView(image: NSImage(named: imageName)!) } - - + + for image in images { statusBarStackView.addArrangedSubview(image) image.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ image.widthAnchor.constraint(equalToConstant: imageWidth), image.heightAnchor.constraint(equalToConstant: imageWidth) - + ]) if #available(OSX 10.14, *) { image.contentTintColor = .labelColor @@ -237,31 +243,31 @@ extension PreferencesViewController { statusBarStackView.addArrangedSubview(dateTimeLabel) NSLayoutConstraint.activate([dateTimeLabel.heightAnchor.constraint(equalToConstant: imageWidth) ]) - + NSLayoutConstraint.activate([ arrowPointToHiddenImage.centerXAnchor.constraint(equalTo: statusBarStackView.arrangedSubviews[3].centerXAnchor) ]) } - + func alwayHideStatusBar() { lblAlwayHidden.isHidden = false arrowPointToAlwayHiddenImage.isHidden = false statusBarStackView.removeAllSubViews() let imageWidth: CGFloat = 16 - - + + let images = ["ico_1","ico_2","ico_3","ico_4", "seprated_1","ico_5","ico_6","seprated", "ico_collapse","ico_7"].map { imageName in NSImageView(image: NSImage(named: imageName)!) } - - + + for image in images { statusBarStackView.addArrangedSubview(image) image.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ image.widthAnchor.constraint(equalToConstant: imageWidth), image.heightAnchor.constraint(equalToConstant: imageWidth) - + ]) if #available(OSX 10.14, *) { image.contentTintColor = .labelColor @@ -279,7 +285,7 @@ extension PreferencesViewController { statusBarStackView.addArrangedSubview(dateTimeLabel) NSLayoutConstraint.activate([dateTimeLabel.heightAnchor.constraint(equalToConstant: imageWidth) ]) - + NSLayoutConstraint.activate([ arrowPointToAlwayHiddenImage.centerXAnchor.constraint(equalTo: statusBarStackView.arrangedSubviews[4].centerXAnchor) ]) @@ -287,16 +293,16 @@ extension PreferencesViewController { arrowPointToHiddenImage.centerXAnchor.constraint(equalTo: statusBarStackView.arrangedSubviews[7].centerXAnchor) ]) } - + @IBAction func btnAlwayHiddenHelpPressed(_ sender: NSButton) { self.showHowToUseAlwayHiddenPopover(sender: sender) } - + private func showHowToUseAlwayHiddenPopover(sender: NSButton) { let controller = NSViewController() let label = NSTextField() let text = NSLocalizedString("Tutorial text", comment: "Step by step tutorial") - + label.stringValue = text label.isBezeled = false label.isEditable = false @@ -310,14 +316,14 @@ extension PreferencesViewController { ]) label.translatesAutoresizingMaskIntoConstraints = false controller.view = view - + let popover = NSPopover() popover.contentViewController = controller popover.contentSize = controller.view.frame.size - + popover.behavior = .transient popover.animates = true - + popover.show(relativeTo: self.view.bounds, of: sender , preferredEdge: NSRectEdge.maxX) } } diff --git a/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift b/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift new file mode 100644 index 0000000..8208c94 --- /dev/null +++ b/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift @@ -0,0 +1,241 @@ +// +// HiddenItemsBarPanelController.swift +// Hidden Bar +// +// Copyright © 2026 Dwarves Foundation. All rights reserved. +// + +import AppKit + +struct HiddenItemsBarCapture { + let items: [HiddenItemsBarItem] + let screen: NSScreen +} + +struct HiddenItemsBarItem { + let image: NSImage + let sourceRect: CGRect +} + +final class HiddenItemsBarPanelController: NSObject { + private let panel: NSPanel + private let scrollView: NSScrollView + private let contentView: HiddenItemsBarView + + var isVisible: Bool { + panel.isVisible + } + + override init() { + contentView = HiddenItemsBarView(frame: .zero) + scrollView = NSScrollView(frame: .zero) + panel = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + super.init() + + scrollView.documentView = contentView + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + + panel.contentView = scrollView + panel.backgroundColor = .clear + panel.hasShadow = true + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.isReleasedWhenClosed = false + panel.level = .statusBar + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.ignoresMouseEvents = false + } + + func show(capture: HiddenItemsBarCapture, clickHandler: @escaping (CGFloat) -> Void) { + contentView.configure(items: capture.items, clickHandler: clickHandler) + + let contentSize = contentView.preferredContentSize + let panelWidth = max(160, min(contentSize.width, capture.screen.visibleFrame.width - 16)) + let needsHorizontalScroll = contentSize.width > panelWidth + let scrollerHeight = needsHorizontalScroll ? NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay) : 0 + let panelHeight = max(28, contentSize.height + scrollerHeight) + let screenFrame = capture.screen.frame + let visibleFrame = capture.screen.visibleFrame + let itemsMidX = capture.items.reduce(0) { $0 + $1.sourceRect.midX } / CGFloat(max(capture.items.count, 1)) + let centeredX = itemsMidX - panelWidth / 2 + let minX = screenFrame.minX + 8 + let maxX = screenFrame.maxX - panelWidth - 8 + let panelX = min(max(centeredX, minX), maxX) + let panelY = max(visibleFrame.maxY - panelHeight - 4, visibleFrame.minY + 8) + + contentView.frame = NSRect(origin: .zero, size: contentSize) + scrollView.hasHorizontalScroller = needsHorizontalScroll + scrollView.frame = NSRect(origin: .zero, size: CGSize(width: panelWidth, height: panelHeight)) + panel.setFrame(NSRect(x: panelX, y: panelY, width: panelWidth, height: panelHeight), display: true) + panel.orderFrontRegardless() + } + + func hide() { + panel.orderOut(nil) + } +} + +final class HiddenItemsBarCaptureShieldController: NSObject { + private let panel: NSPanel + + override init() { + panel = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + super.init() + + panel.backgroundColor = .black + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.isReleasedWhenClosed = false + panel.level = .screenSaver + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.ignoresMouseEvents = true + } + + func show(on screen: NSScreen, near expandCollapseFrame: CGRect) { + let menuBarHeight = max(22, screen.frame.maxY - screen.visibleFrame.maxY) + let shieldPadding: CGFloat = 12 + let shieldFrame: CGRect + + if Constant.isUsingLTRLanguage { + let minX = max(screen.frame.minX, expandCollapseFrame.minX - 360) + shieldFrame = CGRect( + x: minX, + y: screen.frame.maxY - menuBarHeight, + width: max(0, expandCollapseFrame.minX - minX + shieldPadding), + height: menuBarHeight + ) + } else { + let maxX = min(screen.frame.maxX, expandCollapseFrame.maxX + 360) + shieldFrame = CGRect( + x: expandCollapseFrame.maxX - shieldPadding, + y: screen.frame.maxY - menuBarHeight, + width: max(0, maxX - expandCollapseFrame.maxX + shieldPadding), + height: menuBarHeight + ) + } + + guard shieldFrame.width > 1 && shieldFrame.height > 1 else { return } + panel.setFrame(shieldFrame, display: true) + panel.orderFrontRegardless() + } + + func hide() { + panel.orderOut(nil) + } +} + +final class HiddenItemsBarView: NSView { + private enum Metrics { + static let paddingX: CGFloat = 12 + static let paddingY: CGFloat = 5 + static let spacing: CGFloat = 2 + static let minItemHeight: CGFloat = 18 + } + + private var items: [HiddenItemsBarItem] = [] + private var itemRects: [CGRect] = [] + private var clickHandler: ((CGFloat) -> Void)? + + var preferredContentSize: CGSize { + let itemWidth = items.reduce(CGFloat(0)) { $0 + max($1.image.size.width, 1) } + let spacingWidth = CGFloat(max(items.count - 1, 0)) * Metrics.spacing + let itemHeight = items.map { max($0.image.size.height, Metrics.minItemHeight) }.max() ?? Metrics.minItemHeight + return CGSize( + width: itemWidth + spacingWidth + Metrics.paddingX * 2, + height: itemHeight + Metrics.paddingY * 2 + ) + } + + func configure(items: [HiddenItemsBarItem], clickHandler: @escaping (CGFloat) -> Void) { + self.items = items + itemRects = [] + self.clickHandler = clickHandler + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let path = NSBezierPath(roundedRect: bounds, xRadius: 8, yRadius: 8) + NSColor.windowBackgroundColor.withAlphaComponent(0.92).setFill() + path.fill() + + let strokeColor: NSColor + if #available(OSX 10.14, *) { + strokeColor = NSColor.separatorColor + } else { + strokeColor = NSColor.lightGray + } + strokeColor.withAlphaComponent(0.35).setStroke() + path.lineWidth = 1 + path.stroke() + + guard !items.isEmpty else { + drawEmptyState() + return + } + + itemRects = layoutItemRects() + for (index, item) in items.enumerated() { + guard itemRects.indices.contains(index) else { continue } + item.image.draw(in: itemRects[index], from: .zero, operation: .sourceOver, fraction: 1) + } + } + + override func mouseUp(with event: NSEvent) { + guard !items.isEmpty else { return } + + let location = convert(event.locationInWindow, from: nil) + let rects = itemRects.isEmpty ? layoutItemRects() : itemRects + guard let index = rects.firstIndex(where: { $0.contains(location) }) else { return } + clickHandler?(items[index].sourceRect.midX) + } + + private func layoutItemRects() -> [CGRect] { + let contentSize = preferredContentSize + var currentX = (bounds.width - (contentSize.width - Metrics.paddingX * 2)) / 2 + let itemHeight = contentSize.height - Metrics.paddingY * 2 + let originY = (bounds.height - itemHeight) / 2 + + return items.map { item in + let size = item.image.size + let rect = CGRect( + x: currentX, + y: originY + (itemHeight - size.height) / 2, + width: size.width, + height: size.height + ) + currentX += size.width + Metrics.spacing + return rect + } + } + + private func drawEmptyState() { + let text = "Hidden items unavailable".localized + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.secondaryLabelColor, + .font: NSFont.systemFont(ofSize: 12) + ] + let size = text.size(withAttributes: attributes) + text.draw( + at: NSPoint(x: (bounds.width - size.width) / 2, y: (bounds.height - size.height) / 2), + withAttributes: attributes + ) + } +} diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index 1d17718..94fcaf5 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -7,82 +7,90 @@ // import AppKit +import ApplicationServices 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 - + private var btnHiddenLength: CGFloat = 20 private var btnHiddenCollapseLength: CGFloat = 2000 - + private var btnAlwaysHiddenLength: CGFloat = Preferences.alwaysHiddenSectionEnabled ? 20 : 0 private var btnAlwaysHiddenEnableExpandCollapseLength: CGFloat = Preferences.alwaysHiddenSectionEnabled ? 2000 : 0 - + private let imgIconLine = NSImage(named:NSImage.Name("ic_line")) - + private let hiddenItemsBarController = HiddenItemsBarPanelController() + private let hiddenItemsCaptureShieldController = HiddenItemsBarCaptureShieldController() + private var isCollapsed: Bool { return self.btnSeparate.length == self.btnHiddenCollapseLength } - + + private var isSeparateHiddenItemsBarVisible: Bool { + return self.hiddenItemsBarController.isVisible + } + private var isBtnSeparateValidPosition: Bool { guard let btnExpandCollapseX = self.btnExpandCollapse.button?.getOrigin?.x, let btnSeparateX = self.btnSeparate.button?.getOrigin?.x else {return false} - + if Constant.isUsingLTRLanguage { return btnExpandCollapseX >= btnSeparateX } else { return btnExpandCollapseX <= btnSeparateX } } - + private var isBtnAlwaysHiddenValidPosition: Bool { if !Preferences.alwaysHiddenSectionEnabled { return true } - + guard let btnSeparateX = self.btnSeparate.button?.getOrigin?.x, let btnAlwaysHiddenX = self.btnAlwaysHidden?.button?.getOrigin?.x else {return false} - + if Constant.isUsingLTRLanguage { return btnSeparateX >= btnAlwaysHiddenX } else { return btnSeparateX <= btnAlwaysHiddenX } } - + private var isToggle = false - + //MARK: - Methods init() { updateCollapsedLengths() setupUI() setupAlwayHideStatusBar() NotificationCenter.default.addObserver(self, selector: #selector(handleScreenParametersChanged), name: NSApplication.didChangeScreenParametersNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handlePreferencesChanged), name: .prefsChanged, object: nil) DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { self.collapseMenuBar() }) - + if Preferences.areSeparatorsHidden {hideSeparators()} autoCollapseIfNeeded() } - + deinit { NotificationCenter.default.removeObserver(self) } - + @objc private func handleScreenParametersChanged() { updateCollapsedLengths() } - + private func updateCollapsedLengths() { let screenWidth = NSScreen.main?.visibleFrame.width ?? 1728 // Keep collapse length bounded to avoid pathological layout/memory behavior @@ -91,7 +99,18 @@ class StatusBarController { btnHiddenCollapseLength = boundedCollapseLength btnAlwaysHiddenEnableExpandCollapseLength = Preferences.alwaysHiddenSectionEnabled ? boundedCollapseLength : 0 } - + + @objc private func handlePreferencesChanged() { + if !Preferences.showHiddenItemsInSeparateBar { + hiddenItemsBarController.hide() + if let button = btnExpandCollapse.button { + button.image = isCollapsed ? Assets.expandImage : Assets.collapseImage + } + } + updateAutoCollapseMenuTitle() + autoCollapseIfNeeded() + } + private func setupUI() { if let button = btnSeparate.button { button.image = self.imgIconLine @@ -100,24 +119,24 @@ class StatusBarController { btnSeparate.menu = menu updateAutoCollapseMenuTitle() - + if let button = btnExpandCollapse.button { button.image = Assets.collapseImage button.target = self - + button.action = #selector(self.btnExpandCollapsePressed(sender:)) button.sendAction(on: [.leftMouseUp, .rightMouseUp]) } - + btnExpandCollapse.autosaveName = "hiddenbar_expandcollapse"; btnSeparate.autosaveName = "hiddenbar_separate"; } - + @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{ self.expandCollapseIfNeeded() } else { @@ -125,49 +144,61 @@ class StatusBarController { } } } - + func showHideSeparatorsAndAlwayHideArea() { Preferences.areSeparatorsHidden ? self.showSeparators() : self.hideSeparators() - + if self.isCollapsed {self.expandMenubar()} } - + private func showSeparators() { Preferences.areSeparatorsHidden = false - + if !self.isCollapsed { self.btnSeparate.length = self.btnHiddenLength } self.btnAlwaysHidden?.length = self.btnAlwaysHiddenLength } - + private func hideSeparators() { guard self.isBtnAlwaysHiddenValidPosition else {return} - + Preferences.areSeparatorsHidden = true - + if !self.isCollapsed { self.btnSeparate.length = self.btnHiddenLength } self.btnAlwaysHidden?.length = self.btnAlwaysHiddenEnableExpandCollapseLength } - + func expandCollapseIfNeeded() { //prevented rapid click cause icon show many in Dock if isToggle {return} isToggle = true - self.isCollapsed ? self.expandMenubar() : self.collapseMenuBar() + if self.isSeparateHiddenItemsBarVisible { + self.collapseMenuBar() + } else if self.isCollapsed && Preferences.showHiddenItemsInSeparateBar { + self.expandHiddenItemsBar() + } else { + self.isCollapsed ? self.expandMenubar() : self.collapseMenuBar() + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.isToggle = false } } - + private func collapseMenuBar() { + hiddenItemsCaptureShieldController.hide() + hiddenItemsBarController.hide() + guard self.isBtnSeparateValidPosition && !self.isCollapsed else { autoCollapseIfNeeded() + if let button = btnExpandCollapse.button { + button.image = Assets.expandImage + } return } - + btnSeparate.length = self.btnHiddenCollapseLength if let button = btnExpandCollapse.button { button.image = Assets.expandImage @@ -179,26 +210,32 @@ class StatusBarController { } private func expandMenubar() { guard self.isCollapsed else {return} + hiddenItemsCaptureShieldController.hide() + hiddenItemsBarController.hide() btnSeparate.length = btnHiddenLength if let button = btnExpandCollapse.button { button.image = Assets.collapseImage } autoCollapseIfNeeded() - + if Preferences.useFullStatusBarOnExpandEnabled { NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) - + } } - + private func autoCollapseIfNeeded() { guard Preferences.isAutoHide else {return} - guard !isCollapsed else { return } - + guard !isSeparateHiddenItemsBarVisible else { + timer?.invalidate() + return + } + guard !isCollapsed || isSeparateHiddenItemsBarVisible else { return } + startTimerToAutoHide() } - + private func startTimerToAutoHide() { timer?.invalidate() self.timer = Timer.scheduledTimer(withTimeInterval: Preferences.numberOfSecondForAutoHide, repeats: false) { [weak self] _ in @@ -209,14 +246,14 @@ class StatusBarController { } } } - + private func getContextMenu() -> NSMenu { let menu = NSMenu() - + 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 @@ -225,10 +262,10 @@ class StatusBarController { menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem(title: "Quit".localized, action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) - + return menu } - + private func updateAutoCollapseMenuTitle() { guard let toggleAutoHideItem = btnSeparate.menu?.item(withTag: 1) else { return } if Preferences.isAutoHide { @@ -237,21 +274,317 @@ class StatusBarController { toggleAutoHideItem.title = "Enable Auto Collapse".localized } } - + @objc func updateAutoHide() { - updateAutoCollapseMenuTitle() - autoCollapseIfNeeded() + handlePreferencesChanged() } - + @objc func openPreferenceViewControllerIfNeeded() { Util.showPrefWindow() } - + @objc func toggleAutoHide() { Preferences.isAutoHide.toggle() } } +//MARK: - Separate hidden items bar +extension StatusBarController { + private enum SeparateBarTiming { + static let captureDelay: DispatchTimeInterval = .milliseconds(80) + static let showDelayAfterCollapse: DispatchTimeInterval = .milliseconds(160) + } + + private func expandHiddenItemsBar() { + guard self.isCollapsed else { return } + guard self.isBtnSeparateValidPosition else { return } + guard self.canCaptureScreenForSeparatePanel() else { + self.expandMenubar() + return + } + guard + let expandCollapseFrame = btnExpandCollapse.button?.window?.frame, + let screen = btnExpandCollapse.button?.window?.screen ?? NSScreen.main ?? NSScreen.screens.first + else { return } + + timer?.invalidate() + hiddenItemsCaptureShieldController.show(on: screen, near: expandCollapseFrame) + btnSeparate.length = btnHiddenLength + if let button = btnExpandCollapse.button { + button.image = Assets.collapseImage + } + + DispatchQueue.main.asyncAfter(deadline: .now() + SeparateBarTiming.captureDelay) { [weak self] in + guard let self = self else { return } + guard Preferences.showHiddenItemsInSeparateBar else { + self.hiddenItemsCaptureShieldController.hide() + self.collapseMenuBar() + return + } + + guard let capture = self.captureExpandedHiddenItems() else { + self.collapseMenuBarForSeparatePanel() + self.hiddenItemsCaptureShieldController.hide() + return + } + + self.collapseMenuBarForSeparatePanel() + DispatchQueue.main.asyncAfter(deadline: .now() + SeparateBarTiming.showDelayAfterCollapse) { [weak self] in + guard let self = self else { return } + self.hiddenItemsCaptureShieldController.hide() + guard Preferences.showHiddenItemsInSeparateBar else { return } + + self.hiddenItemsBarController.show(capture: capture) { [weak self] sourceX in + self?.activateHiddenItem(atSourceX: sourceX, from: capture) + } + if let button = self.btnExpandCollapse.button { + button.image = Assets.collapseImage + } + self.autoCollapseIfNeeded() + } + } + } + + private func canCaptureScreenForSeparatePanel() -> Bool { + if #available(OSX 10.15, *) { + guard CGPreflightScreenCaptureAccess() else { + CGRequestScreenCaptureAccess() + return false + } + } + return true + } + + private func collapseMenuBarForSeparatePanel() { + hiddenItemsBarController.hide() + btnSeparate.length = btnHiddenCollapseLength + if let button = btnExpandCollapse.button { + button.image = Assets.expandImage + } + if Preferences.useFullStatusBarOnExpandEnabled { + NSApp.setActivationPolicy(.accessory) + NSApp.deactivate() + } + } + + private func captureExpandedHiddenItems() -> HiddenItemsBarCapture? { + guard + let separateFrame = btnSeparate.button?.window?.frame, + let expandCollapseFrame = btnExpandCollapse.button?.window?.frame + else { return nil } + + let screen = btnExpandCollapse.button?.window?.screen ?? NSScreen.main ?? NSScreen.screens.first + guard let targetScreen = screen else { return nil } + + let items = captureVisibleHiddenSectionItems( + separatedBy: separateFrame, + expandCollapseFrame: expandCollapseFrame, + on: targetScreen + ) + guard !items.isEmpty else { return nil } + return HiddenItemsBarCapture(items: items, screen: targetScreen) + } + + private func captureVisibleHiddenSectionItems(separatedBy separatorFrame: CGRect, expandCollapseFrame: CGRect, on screen: NSScreen) -> [HiddenItemsBarItem] { + guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { + return [] + } + + let separatorQuartzRect = quartzRectFromAppKitRect(separatorFrame) + let expandCollapseQuartzRect = quartzRectFromAppKitRect(expandCollapseFrame) + + let capturedItems = windowList.compactMap { info -> (item: HiddenItemsBarItem, quartzRect: CGRect)? in + guard + let windowNumber = info[kCGWindowNumber as String] as? Int, + let quartzRect = visibleMenuBarItemQuartzRect(from: info, on: screen), + let image = CGWindowListCreateImage( + .null, + [.optionIncludingWindow], + CGWindowID(windowNumber), + [.boundsIgnoreFraming, .bestResolution] + ) + else { + return nil + } + + let appKitRect = appKitRectFromQuartzRect(quartzRect, on: screen) + return ( + item: HiddenItemsBarItem( + image: NSImage(cgImage: image, size: appKitRect.size), + sourceRect: appKitRect + ), + quartzRect: quartzRect + ) + } + .sorted { $0.item.sourceRect.minX < $1.item.sourceRect.minX } + + let hiddenSectionItems = capturedItems.filter { + hiddenSectionCandidateLocation( + $0.quartzRect, + separatorQuartzRect: separatorQuartzRect, + expandCollapseQuartzRect: expandCollapseQuartzRect + ) != nil + } + + if !hiddenSectionItems.isEmpty { + return hiddenSectionItems.map { $0.item } + } + + return expandedItemsAdjacentToArrow( + capturedItems, + expandCollapseQuartzRect: expandCollapseQuartzRect, + screenQuartzRect: quartzRectFromAppKitRect(screen.frame) + ) + } + + private func visibleMenuBarItemQuartzRect(from info: [String: Any], on screen: NSScreen) -> CGRect? { + let currentProcessID = ProcessInfo.processInfo.processIdentifier + let screenQuartzRect = quartzRectFromAppKitRect(screen.frame) + let menuBarHeight = max(22, screen.frame.maxY - screen.visibleFrame.maxY) + let maxMenuBarQuartzY = screenQuartzRect.minY + menuBarHeight + 6 + let appBundleIdentifier = Bundle.main.bundleIdentifier + + guard + (info[kCGWindowOwnerPID as String] as? Int32) != currentProcessID, + !isHiddenBarStatusWindow(info, appBundleIdentifier: appBundleIdentifier), + let layer = info[kCGWindowLayer as String] as? Int, + layer == 25, + let bounds = info[kCGWindowBounds as String] as? [String: Any], + let quartzRect = rectFromWindowBounds(bounds), + quartzRect.intersects(screenQuartzRect), + quartzRect.minY >= screenQuartzRect.minY - 1, + quartzRect.minY <= maxMenuBarQuartzY, + quartzRect.height > 4, + quartzRect.width > 4 + else { + return nil + } + + return quartzRect + } + + private enum HiddenSectionCandidateLocation { + case hiddenSection + } + + private func hiddenSectionCandidateLocation(_ quartzRect: CGRect, separatorQuartzRect: CGRect, expandCollapseQuartzRect: CGRect) -> HiddenSectionCandidateLocation? { + if expandCollapseQuartzRect.minX >= separatorQuartzRect.minX { + if quartzRect.maxX <= separatorQuartzRect.minX + 1 { + return .hiddenSection + } + } else { + if quartzRect.minX >= separatorQuartzRect.maxX - 1 { + return .hiddenSection + } + } + + return nil + } + + private func expandedItemsAdjacentToArrow( + _ capturedItems: [(item: HiddenItemsBarItem, quartzRect: CGRect)], + expandCollapseQuartzRect: CGRect, + screenQuartzRect: CGRect + ) -> [HiddenItemsBarItem] { + if Constant.isUsingLTRLanguage { + return capturedItems + .filter { $0.quartzRect.maxX <= expandCollapseQuartzRect.minX + 1 && $0.quartzRect.minX >= screenQuartzRect.minX } + .map { $0.item } + } else { + return capturedItems + .filter { $0.quartzRect.minX >= expandCollapseQuartzRect.maxX - 1 && $0.quartzRect.maxX <= screenQuartzRect.maxX } + .map { $0.item } + } + } + + private func isHiddenBarStatusWindow(_ info: [String: Any], appBundleIdentifier: String?) -> Bool { + let title = info[kCGWindowName as String] as? String + return title == appBundleIdentifier || title?.hasPrefix("hiddenbar_") == true + } + + private func rectFromWindowBounds(_ bounds: [String: Any]) -> CGRect? { + guard + let x = bounds["X"] as? NSNumber, + let y = bounds["Y"] as? NSNumber, + let width = bounds["Width"] as? NSNumber, + let height = bounds["Height"] as? NSNumber + else { + return nil + } + return CGRect( + x: CGFloat(truncating: x), + y: CGFloat(truncating: y), + width: CGFloat(truncating: width), + height: CGFloat(truncating: height) + ) + } + + private func appKitRectFromQuartzRect(_ quartzRect: CGRect, on screen: NSScreen) -> CGRect { + let referenceMaxY = NSScreen.main?.frame.maxY ?? screen.frame.maxY + return CGRect( + x: quartzRect.minX, + y: referenceMaxY - quartzRect.maxY, + width: quartzRect.width, + height: quartzRect.height + ) + } + + private func quartzRectFromAppKitRect(_ appKitRect: CGRect) -> CGRect { + let referenceMaxY = NSScreen.main?.frame.maxY ?? appKitRect.maxY + return CGRect( + x: appKitRect.minX, + y: referenceMaxY - appKitRect.maxY, + width: appKitRect.width, + height: appKitRect.height + ) + } + + private func activateHiddenItem(atSourceX sourceX: CGFloat, from capture: HiddenItemsBarCapture) { + guard canForwardClicksToMenuBarItems() else { return } + + hiddenItemsBarController.hide() + btnSeparate.length = btnHiddenLength + if let button = btnExpandCollapse.button { + button.image = Assets.collapseImage + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + guard let self = self else { return } + let clickPoint = CGPoint( + x: sourceX, + y: capture.items.first(where: { $0.sourceRect.minX <= sourceX && sourceX <= $0.sourceRect.maxX })?.sourceRect.midY ?? capture.screen.frame.maxY - 12 + ) + self.postClick(at: clickPoint) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.collapseMenuBar() + } + } + } + + private func postClick(at appKitPoint: CGPoint) { + let referenceMaxY = NSScreen.main?.frame.maxY ?? appKitPoint.y + let eventPoint = CGPoint(x: appKitPoint.x, y: referenceMaxY - appKitPoint.y) + guard + let mouseDown = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: eventPoint, mouseButton: .left), + let mouseUp = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: eventPoint, mouseButton: .left) + else { return } + + mouseDown.post(tap: CGEventTapLocation.cghidEventTap) + mouseUp.post(tap: CGEventTapLocation.cghidEventTap) + } + + private func canForwardClicksToMenuBarItems() -> Bool { + guard !AXIsProcessTrusted() else { return true } + + let options = [ + kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true + ] as CFDictionary + AXIsProcessTrustedWithOptions(options) + return false + } +} + //MARK: - Alway hide feature extension StatusBarController { @@ -261,7 +594,7 @@ extension StatusBarController { } @objc private func toggleStatusBarIfNeeded() { updateCollapsedLengths() - + if Preferences.alwaysHiddenSectionEnabled { if let existing = self.btnAlwaysHidden { NSStatusBar.system.removeStatusItem(existing) diff --git a/hidden/en.lproj/Localizable.strings b/hidden/en.lproj/Localizable.strings index eab8db1..f94f802 100644 --- a/hidden/en.lproj/Localizable.strings +++ b/hidden/en.lproj/Localizable.strings @@ -10,6 +10,8 @@ "Toggle Auto Collapse" = "Toggle Auto Collapse"; "Enable Auto Collapse" = "Enable Auto Collapse"; "Disable Auto Collapse" = "Disable Auto Collapse"; +"Show hidden items in separate bar" = "Show hidden items in separate bar"; +"Hidden items unavailable" = "Hidden items unavailable"; "Quit" = "Quit"; "Set Shortcut" = "Set Shortcut"; "Tutorial text" = " From 7c34a20eda688ab345a702f7247c9d29b7222d0c Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sun, 31 May 2026 00:10:05 -0700 Subject: [PATCH 2/7] fix: recover separate bar after invalid separator drag When the separator is command-dragged into an invalid position, the separate-bar path could return while the separator was still using the collapsed spacer length. Restore the inline menu bar instead so the separator reappears and the user can recover the layout. --- .../StatusBar/StatusBarController.swift | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index 94fcaf5..6b03966 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -192,6 +192,10 @@ class StatusBarController { hiddenItemsBarController.hide() guard self.isBtnSeparateValidPosition && !self.isCollapsed else { + if !self.isBtnSeparateValidPosition { + restoreInlineMenuBarAfterInvalidSeparatePosition() + return + } autoCollapseIfNeeded() if let button = btnExpandCollapse.button { button.image = Assets.expandImage @@ -297,7 +301,10 @@ extension StatusBarController { private func expandHiddenItemsBar() { guard self.isCollapsed else { return } - guard self.isBtnSeparateValidPosition else { return } + guard self.isBtnSeparateValidPosition else { + restoreInlineMenuBarAfterInvalidSeparatePosition() + return + } guard self.canCaptureScreenForSeparatePanel() else { self.expandMenubar() return @@ -328,11 +335,16 @@ extension StatusBarController { return } - self.collapseMenuBarForSeparatePanel() + guard self.collapseMenuBarForSeparatePanel() else { return } DispatchQueue.main.asyncAfter(deadline: .now() + SeparateBarTiming.showDelayAfterCollapse) { [weak self] in guard let self = self else { return } self.hiddenItemsCaptureShieldController.hide() - guard Preferences.showHiddenItemsInSeparateBar else { return } + guard Preferences.showHiddenItemsInSeparateBar && self.isBtnSeparateValidPosition else { + if !self.isBtnSeparateValidPosition { + self.restoreInlineMenuBarAfterInvalidSeparatePosition() + } + return + } self.hiddenItemsBarController.show(capture: capture) { [weak self] sourceX in self?.activateHiddenItem(atSourceX: sourceX, from: capture) @@ -355,8 +367,12 @@ extension StatusBarController { return true } - private func collapseMenuBarForSeparatePanel() { + private func collapseMenuBarForSeparatePanel() -> Bool { hiddenItemsBarController.hide() + guard self.isBtnSeparateValidPosition else { + restoreInlineMenuBarAfterInvalidSeparatePosition() + return false + } btnSeparate.length = btnHiddenCollapseLength if let button = btnExpandCollapse.button { button.image = Assets.expandImage @@ -365,6 +381,22 @@ extension StatusBarController { NSApp.setActivationPolicy(.accessory) NSApp.deactivate() } + return true + } + + private func restoreInlineMenuBarAfterInvalidSeparatePosition() { + timer?.invalidate() + hiddenItemsCaptureShieldController.hide() + hiddenItemsBarController.hide() + btnSeparate.length = btnHiddenLength + if let button = btnExpandCollapse.button { + button.image = Assets.collapseImage + } + + if Preferences.useFullStatusBarOnExpandEnabled { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } } private func captureExpandedHiddenItems() -> HiddenItemsBarCapture? { From 5ced19e4e1434c4d2f2a4efbf3885fe69e821822 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sun, 31 May 2026 00:23:13 -0700 Subject: [PATCH 3/7] fix: avoid duplicate separate bar captures Reject separate-bar captures when the separator is not on the active menu bar row, and fall back to inline expansion instead of cloning visible items near the arrow. Keep auto-collapse active for a visible separate panel and force the inline fallback through the normal expansion side effects for full-status mode. --- .../StatusBar/StatusBarController.swift | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index 6b03966..3cd327c 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -196,6 +196,7 @@ class StatusBarController { restoreInlineMenuBarAfterInvalidSeparatePosition() return } + timer?.invalidate() autoCollapseIfNeeded() if let button = btnExpandCollapse.button { button.image = Assets.expandImage @@ -212,8 +213,8 @@ class StatusBarController { NSApp.deactivate() } } - private func expandMenubar() { - guard self.isCollapsed else {return} + private func expandMenubar(force: Bool = false) { + guard self.isCollapsed || force else {return} hiddenItemsCaptureShieldController.hide() hiddenItemsBarController.hide() btnSeparate.length = btnHiddenLength @@ -231,10 +232,6 @@ class StatusBarController { private func autoCollapseIfNeeded() { guard Preferences.isAutoHide else {return} - guard !isSeparateHiddenItemsBarVisible else { - timer?.invalidate() - return - } guard !isCollapsed || isSeparateHiddenItemsBarVisible else { return } startTimerToAutoHide() @@ -330,8 +327,8 @@ extension StatusBarController { } guard let capture = self.captureExpandedHiddenItems() else { - self.collapseMenuBarForSeparatePanel() self.hiddenItemsCaptureShieldController.hide() + self.expandMenubar(force: true) return } @@ -407,6 +404,7 @@ extension StatusBarController { let screen = btnExpandCollapse.button?.window?.screen ?? NSScreen.main ?? NSScreen.screens.first guard let targetScreen = screen else { return nil } + guard isMenuBarFrame(separateFrame, on: targetScreen) else { return nil } let items = captureVisibleHiddenSectionItems( separatedBy: separateFrame, @@ -462,11 +460,7 @@ extension StatusBarController { return hiddenSectionItems.map { $0.item } } - return expandedItemsAdjacentToArrow( - capturedItems, - expandCollapseQuartzRect: expandCollapseQuartzRect, - screenQuartzRect: quartzRectFromAppKitRect(screen.frame) - ) + return [] } private func visibleMenuBarItemQuartzRect(from info: [String: Any], on screen: NSScreen) -> CGRect? { @@ -513,20 +507,15 @@ extension StatusBarController { return nil } - private func expandedItemsAdjacentToArrow( - _ capturedItems: [(item: HiddenItemsBarItem, quartzRect: CGRect)], - expandCollapseQuartzRect: CGRect, - screenQuartzRect: CGRect - ) -> [HiddenItemsBarItem] { - if Constant.isUsingLTRLanguage { - return capturedItems - .filter { $0.quartzRect.maxX <= expandCollapseQuartzRect.minX + 1 && $0.quartzRect.minX >= screenQuartzRect.minX } - .map { $0.item } - } else { - return capturedItems - .filter { $0.quartzRect.minX >= expandCollapseQuartzRect.maxX - 1 && $0.quartzRect.maxX <= screenQuartzRect.maxX } - .map { $0.item } - } + private func isMenuBarFrame(_ frame: CGRect, on screen: NSScreen) -> Bool { + let menuBarHeight = max(22, screen.frame.maxY - screen.visibleFrame.maxY) + let menuBarFrame = CGRect( + x: screen.frame.minX, + y: screen.frame.maxY - menuBarHeight - 2, + width: screen.frame.width, + height: menuBarHeight + 4 + ) + return frame.intersects(menuBarFrame) } private func isHiddenBarStatusWindow(_ info: [String: Any], appBundleIdentifier: String?) -> Bool { From 4fafe4f15ae309128d82d2e94377e33e740030f2 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sun, 31 May 2026 02:38:00 -0700 Subject: [PATCH 4/7] fix(statusbar): preserve separate bar marker Keep the separate-bar marker anchored to the captured separator location after the real menu bar is collapsed. Mask any captured hidden item that macOS still leaves visible in the menu bar so it stays in the detached panel instead of appearing twice. Refresh status-item geometry from the live status item before falling back to cached click data, preventing hotkey opens from using stale menu-bar positions. Refs: #357 --- .../HiddenItemsBarPanelController.swift | 85 +++++++ .../StatusBar/StatusBarController.swift | 225 +++++++++++++++--- 2 files changed, 277 insertions(+), 33 deletions(-) diff --git a/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift b/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift index 8208c94..8d017a8 100644 --- a/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift +++ b/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift @@ -10,11 +10,14 @@ import AppKit struct HiddenItemsBarCapture { let items: [HiddenItemsBarItem] let screen: NSScreen + let separatorFrame: CGRect + let menuBarOverlayFrame: CGRect } struct HiddenItemsBarItem { let image: NSImage let sourceRect: CGRect + let windowNumber: Int } final class HiddenItemsBarPanelController: NSObject { @@ -140,6 +143,88 @@ final class HiddenItemsBarCaptureShieldController: NSObject { } } +final class HiddenItemsBarSeparatorOverlayController: NSObject { + private let panel: NSPanel + private let contentView: HiddenItemsBarSeparatorOverlayView + + override init() { + contentView = HiddenItemsBarSeparatorOverlayView(frame: .zero) + panel = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + super.init() + + panel.contentView = contentView + panel.backgroundColor = .clear + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.isOpaque = false + panel.isReleasedWhenClosed = false + panel.level = .screenSaver + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.ignoresMouseEvents = true + } + + func show(frame: CGRect, separatorFrame: CGRect) { + guard frame.width > 1 && frame.height > 1 else { + hide() + return + } + + contentView.frame = NSRect(origin: .zero, size: frame.size) + contentView.separatorFrame = separatorFrame.offsetBy(dx: -frame.minX, dy: -frame.minY) + contentView.needsDisplay = true + panel.setFrame(frame, display: true) + panel.orderFrontRegardless() + } + + func hide() { + panel.orderOut(nil) + } +} + +final class HiddenItemsBarSeparatorOverlayView: NSView { + var separatorFrame: CGRect = .zero + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + overlayBackgroundColor.setFill() + dirtyRect.fill() + + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: separatorColor, + .font: NSFont.systemFont(ofSize: 18) + ] + let text = "|" + let size = text.size(withAttributes: attributes) + let targetFrame = separatorFrame.isEmpty ? bounds : separatorFrame + text.draw( + at: NSPoint(x: targetFrame.midX - size.width / 2, y: (bounds.height - size.height) / 2), + withAttributes: attributes + ) + } + + private var overlayBackgroundColor: NSColor { + isDarkAppearance ? NSColor.black : NSColor.windowBackgroundColor + } + + private var separatorColor: NSColor { + isDarkAppearance ? NSColor.white : NSColor.labelColor + } + + private var isDarkAppearance: Bool { + if #available(OSX 10.14, *) { + return effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + } + return false + } +} + final class HiddenItemsBarView: NSView { private enum Metrics { static let paddingX: CGFloat = 12 diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index 3cd327c..eda593c 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -29,6 +29,9 @@ class StatusBarController { private let imgIconLine = NSImage(named:NSImage.Name("ic_line")) private let hiddenItemsBarController = HiddenItemsBarPanelController() private let hiddenItemsCaptureShieldController = HiddenItemsBarCaptureShieldController() + private let hiddenItemsSeparatorOverlayController = HiddenItemsBarSeparatorOverlayController() + private var activeExpandCollapseFrame: CGRect? + private var activeStatusItemScreen: NSScreen? private var isCollapsed: Bool { return self.btnSeparate.length == self.btnHiddenCollapseLength @@ -88,6 +91,8 @@ class StatusBarController { } @objc private func handleScreenParametersChanged() { + activeExpandCollapseFrame = nil + activeStatusItemScreen = nil updateCollapsedLengths() } @@ -103,6 +108,7 @@ class StatusBarController { @objc private func handlePreferencesChanged() { if !Preferences.showHiddenItemsInSeparateBar { hiddenItemsBarController.hide() + hiddenItemsSeparatorOverlayController.hide() if let button = btnExpandCollapse.button { button.image = isCollapsed ? Assets.expandImage : Assets.collapseImage } @@ -133,6 +139,9 @@ class StatusBarController { } @objc func btnExpandCollapsePressed(sender: NSStatusBarButton) { + activeExpandCollapseFrame = sender.window?.frame + activeStatusItemScreen = sender.window?.screen + if let event = NSApp.currentEvent { let isOptionKeyPressed = event.modifierFlags.contains(NSEvent.ModifierFlags.option) @@ -164,6 +173,7 @@ class StatusBarController { guard self.isBtnAlwaysHiddenValidPosition else {return} Preferences.areSeparatorsHidden = true + hiddenItemsSeparatorOverlayController.hide() if !self.isCollapsed { self.btnSeparate.length = self.btnHiddenLength @@ -190,6 +200,7 @@ class StatusBarController { private func collapseMenuBar() { hiddenItemsCaptureShieldController.hide() hiddenItemsBarController.hide() + hiddenItemsSeparatorOverlayController.hide() guard self.isBtnSeparateValidPosition && !self.isCollapsed else { if !self.isBtnSeparateValidPosition { @@ -217,6 +228,7 @@ class StatusBarController { guard self.isCollapsed || force else {return} hiddenItemsCaptureShieldController.hide() hiddenItemsBarController.hide() + hiddenItemsSeparatorOverlayController.hide() btnSeparate.length = btnHiddenLength if let button = btnExpandCollapse.button { button.image = Assets.collapseImage @@ -306,13 +318,10 @@ extension StatusBarController { self.expandMenubar() return } - guard - let expandCollapseFrame = btnExpandCollapse.button?.window?.frame, - let screen = btnExpandCollapse.button?.window?.screen ?? NSScreen.main ?? NSScreen.screens.first - else { return } + guard let expandCollapseGeometry = currentExpandCollapseGeometry() else { return } timer?.invalidate() - hiddenItemsCaptureShieldController.show(on: screen, near: expandCollapseFrame) + hiddenItemsCaptureShieldController.show(on: expandCollapseGeometry.screen, near: expandCollapseGeometry.frame) btnSeparate.length = btnHiddenLength if let button = btnExpandCollapse.button { button.image = Assets.collapseImage @@ -342,10 +351,15 @@ extension StatusBarController { } return } + let capture = self.captureByCoveringItemsStillVisibleInMenuBar(capture) self.hiddenItemsBarController.show(capture: capture) { [weak self] sourceX in self?.activateHiddenItem(atSourceX: sourceX, from: capture) } + self.hiddenItemsSeparatorOverlayController.show( + frame: capture.menuBarOverlayFrame, + separatorFrame: capture.separatorFrame + ) if let button = self.btnExpandCollapse.button { button.image = Assets.collapseImage } @@ -385,6 +399,7 @@ extension StatusBarController { timer?.invalidate() hiddenItemsCaptureShieldController.hide() hiddenItemsBarController.hide() + hiddenItemsSeparatorOverlayController.hide() btnSeparate.length = btnHiddenLength if let button = btnExpandCollapse.button { button.image = Assets.collapseImage @@ -397,32 +412,39 @@ extension StatusBarController { } private func captureExpandedHiddenItems() -> HiddenItemsBarCapture? { - guard - let separateFrame = btnSeparate.button?.window?.frame, - let expandCollapseFrame = btnExpandCollapse.button?.window?.frame - else { return nil } - - let screen = btnExpandCollapse.button?.window?.screen ?? NSScreen.main ?? NSScreen.screens.first - guard let targetScreen = screen else { return nil } - guard isMenuBarFrame(separateFrame, on: targetScreen) else { return nil } - - let items = captureVisibleHiddenSectionItems( - separatedBy: separateFrame, - expandCollapseFrame: expandCollapseFrame, - on: targetScreen - ) - guard !items.isEmpty else { return nil } - return HiddenItemsBarCapture(items: items, screen: targetScreen) - } + guard let windowList = menuBarWindowList() else { return nil } + + for screen in preferredCaptureScreens() { + guard + let separatorQuartzRect = statusItemQuartzRect(named: "hiddenbar_separate", statusItem: btnSeparate, from: windowList, on: screen), + let expandCollapseQuartzRect = statusItemQuartzRect(named: "hiddenbar_expandcollapse", statusItem: btnExpandCollapse, from: windowList, on: screen) + else { continue } - private func captureVisibleHiddenSectionItems(separatedBy separatorFrame: CGRect, expandCollapseFrame: CGRect, on screen: NSScreen) -> [HiddenItemsBarItem] { - guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { - return [] + let items = captureVisibleHiddenSectionItems( + from: windowList, + separatorQuartzRect: separatorQuartzRect, + expandCollapseQuartzRect: expandCollapseQuartzRect, + on: screen + ) + if !items.isEmpty { + let separatorFrame = appKitRectFromQuartzRect(separatorQuartzRect, on: screen) + return HiddenItemsBarCapture( + items: items, + screen: screen, + separatorFrame: separatorFrame, + menuBarOverlayFrame: menuBarOverlayFrame( + for: separatorFrame, + covering: [], + on: screen + ) + ) + } } - let separatorQuartzRect = quartzRectFromAppKitRect(separatorFrame) - let expandCollapseQuartzRect = quartzRectFromAppKitRect(expandCollapseFrame) + return nil + } + private func captureVisibleHiddenSectionItems(from windowList: [[String: Any]], separatorQuartzRect: CGRect, expandCollapseQuartzRect: CGRect, on screen: NSScreen) -> [HiddenItemsBarItem] { let capturedItems = windowList.compactMap { info -> (item: HiddenItemsBarItem, quartzRect: CGRect)? in guard let windowNumber = info[kCGWindowNumber as String] as? Int, @@ -441,7 +463,8 @@ extension StatusBarController { return ( item: HiddenItemsBarItem( image: NSImage(cgImage: image, size: appKitRect.size), - sourceRect: appKitRect + sourceRect: appKitRect, + windowNumber: windowNumber ), quartzRect: quartzRect ) @@ -463,6 +486,58 @@ extension StatusBarController { return [] } + private func captureByCoveringItemsStillVisibleInMenuBar(_ capture: HiddenItemsBarCapture) -> HiddenItemsBarCapture { + guard let windowList = menuBarWindowList() else { return capture } + + let visibleRectsByWindowNumber = Dictionary(uniqueKeysWithValues: windowList.compactMap { info -> (Int, CGRect)? in + guard + let windowNumber = info[kCGWindowNumber as String] as? Int, + let quartzRect = visibleMenuBarItemQuartzRect(from: info, on: capture.screen) + else { + return nil + } + + return (windowNumber, appKitRectFromQuartzRect(quartzRect, on: capture.screen)) + }) + let visibleCapturedRects = capture.items.compactMap { visibleRectsByWindowNumber[$0.windowNumber] } + guard !visibleCapturedRects.isEmpty else { return capture } + + return HiddenItemsBarCapture( + items: capture.items, + screen: capture.screen, + separatorFrame: capture.separatorFrame, + menuBarOverlayFrame: menuBarOverlayFrame( + for: capture.separatorFrame, + covering: visibleCapturedRects, + on: capture.screen + ) + ) + } + + private func menuBarOverlayFrame(for separatorFrame: CGRect, covering itemFrames: [CGRect], on screen: NSScreen) -> CGRect { + let menuBarHeight = max(22, screen.frame.maxY - screen.visibleFrame.maxY) + let minX = max( + screen.frame.minX, + itemFrames.reduce(separatorFrame.minX) { min($0, $1.minX) } + ) + let maxX = min( + screen.frame.maxX, + itemFrames.reduce(separatorFrame.maxX) { max($0, $1.maxX) } + ) + let width = max(btnHiddenLength, maxX - minX) + + return CGRect( + x: minX, + y: screen.frame.maxY - menuBarHeight, + width: width, + height: menuBarHeight + ) + } + + private func menuBarWindowList() -> [[String: Any]]? { + CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] + } + private func visibleMenuBarItemQuartzRect(from info: [String: Any], on screen: NSScreen) -> CGRect? { let currentProcessID = ProcessInfo.processInfo.processIdentifier let screenQuartzRect = quartzRectFromAppKitRect(screen.frame) @@ -507,15 +582,98 @@ extension StatusBarController { return nil } - private func isMenuBarFrame(_ frame: CGRect, on screen: NSScreen) -> Bool { + private func preferredCaptureScreens() -> [NSScreen] { + let preferred = btnExpandCollapse.button?.window?.screen ?? activeStatusItemScreen ?? NSScreen.main + guard let first = preferred else { return NSScreen.screens } + + return [first] + NSScreen.screens.filter { $0 !== first } + } + + private func currentExpandCollapseGeometry() -> (frame: CGRect, screen: NSScreen)? { + if + let frame = btnExpandCollapse.button?.window?.frame, + let screen = btnExpandCollapse.button?.window?.screen + { + return (frame, screen) + } + + guard let frame = activeExpandCollapseFrame else { return nil } + let screen = activeStatusItemScreen + ?? NSScreen.screens.first { $0.frame.intersects(frame) } + ?? NSScreen.main + ?? NSScreen.screens.first + guard let resolvedScreen = screen else { return nil } + return (frame, resolvedScreen) + } + + private func statusItemQuartzRect(named name: String, statusItem: NSStatusItem, from windowList: [[String: Any]], on screen: NSScreen) -> CGRect? { + let screenQuartzRect = quartzRectFromAppKitRect(screen.frame) + let menuBarQuartzRect = quartzMenuBarRect(on: screen) + let appBundleIdentifier = Bundle.main.bundleIdentifier + + let namedWindows = windowList.compactMap { info -> CGRect? in + guard + (info[kCGWindowName as String] as? String) == name, + let quartzRect = statusItemWindowQuartzRect(from: info, screenQuartzRect: screenQuartzRect, menuBarQuartzRect: menuBarQuartzRect) + else { + return nil + } + + return quartzRect + } + if let namedWindow = namedWindows.sorted(by: { $0.minX < $1.minX }).first { + return namedWindow + } + + guard let expectedFrame = statusItem.button?.window?.frame else { return nil } + let expectedQuartzRect = quartzRectFromAppKitRect(expectedFrame) + let bundleWindows = windowList.compactMap { info -> CGRect? in + guard + let title = info[kCGWindowName as String] as? String, + title == appBundleIdentifier || title.hasPrefix("hiddenbar_"), + let quartzRect = statusItemWindowQuartzRect(from: info, screenQuartzRect: screenQuartzRect, menuBarQuartzRect: menuBarQuartzRect) + else { + return nil + } + + return quartzRect + } + + return bundleWindows.sorted { + statusItemMatchScore($0, expectedQuartzRect: expectedQuartzRect) < statusItemMatchScore($1, expectedQuartzRect: expectedQuartzRect) + }.first + } + + private func statusItemWindowQuartzRect(from info: [String: Any], screenQuartzRect: CGRect, menuBarQuartzRect: CGRect) -> CGRect? { + guard + let layer = info[kCGWindowLayer as String] as? Int, + layer == 25, + let bounds = info[kCGWindowBounds as String] as? [String: Any], + let quartzRect = rectFromWindowBounds(bounds), + quartzRect.intersects(screenQuartzRect), + quartzRect.intersects(menuBarQuartzRect) + else { + return nil + } + + return quartzRect + } + + private func statusItemMatchScore(_ quartzRect: CGRect, expectedQuartzRect: CGRect) -> CGFloat { + let centerDistance = abs(quartzRect.midX - expectedQuartzRect.midX) + abs(quartzRect.midY - expectedQuartzRect.midY) + let sizeDistance = abs(quartzRect.width - expectedQuartzRect.width) + abs(quartzRect.height - expectedQuartzRect.height) + return centerDistance + sizeDistance + } + + private func quartzMenuBarRect(on screen: NSScreen) -> CGRect { + let screenQuartzRect = quartzRectFromAppKitRect(screen.frame) let menuBarHeight = max(22, screen.frame.maxY - screen.visibleFrame.maxY) - let menuBarFrame = CGRect( - x: screen.frame.minX, - y: screen.frame.maxY - menuBarHeight - 2, + return CGRect( + x: screenQuartzRect.minX, + y: screenQuartzRect.minY - 2, width: screen.frame.width, height: menuBarHeight + 4 ) - return frame.intersects(menuBarFrame) } private func isHiddenBarStatusWindow(_ info: [String: Any], appBundleIdentifier: String?) -> Bool { @@ -564,6 +722,7 @@ extension StatusBarController { guard canForwardClicksToMenuBarItems() else { return } hiddenItemsBarController.hide() + hiddenItemsSeparatorOverlayController.hide() btnSeparate.length = btnHiddenLength if let button = btnExpandCollapse.button { button.image = Assets.collapseImage From 13e78c5afc88f315da573af934ea7f99f4ed492d Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sun, 31 May 2026 12:33:26 -0700 Subject: [PATCH 5/7] fix(statusbar): keep separate bar icons visible Sample captured hidden item luminance before drawing the separate bar so light captured glyphs use a dark floating panel even when macOS appearance alone would choose a light background. --- .../HiddenItemsBarPanelController.swift | 25 ++++++++++--- .../StatusBar/StatusBarController.swift | 37 +++++++++++++++++-- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift b/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift index 8d017a8..a075201 100644 --- a/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift +++ b/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift @@ -12,6 +12,7 @@ struct HiddenItemsBarCapture { let screen: NSScreen let separatorFrame: CGRect let menuBarOverlayFrame: CGRect + let prefersDarkBackground: Bool } struct HiddenItemsBarItem { @@ -60,6 +61,7 @@ final class HiddenItemsBarPanelController: NSObject { } func show(capture: HiddenItemsBarCapture, clickHandler: @escaping (CGFloat) -> Void) { + contentView.prefersDarkBackground = capture.prefersDarkBackground contentView.configure(items: capture.items, clickHandler: clickHandler) let contentSize = contentView.preferredContentSize @@ -169,7 +171,7 @@ final class HiddenItemsBarSeparatorOverlayController: NSObject { panel.ignoresMouseEvents = true } - func show(frame: CGRect, separatorFrame: CGRect) { + func show(frame: CGRect, separatorFrame: CGRect, prefersDarkBackground: Bool) { guard frame.width > 1 && frame.height > 1 else { hide() return @@ -177,6 +179,7 @@ final class HiddenItemsBarSeparatorOverlayController: NSObject { contentView.frame = NSRect(origin: .zero, size: frame.size) contentView.separatorFrame = separatorFrame.offsetBy(dx: -frame.minX, dy: -frame.minY) + contentView.prefersDarkBackground = prefersDarkBackground contentView.needsDisplay = true panel.setFrame(frame, display: true) panel.orderFrontRegardless() @@ -189,6 +192,7 @@ final class HiddenItemsBarSeparatorOverlayController: NSObject { final class HiddenItemsBarSeparatorOverlayView: NSView { var separatorFrame: CGRect = .zero + var prefersDarkBackground = false override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) @@ -210,11 +214,11 @@ final class HiddenItemsBarSeparatorOverlayView: NSView { } private var overlayBackgroundColor: NSColor { - isDarkAppearance ? NSColor.black : NSColor.windowBackgroundColor + prefersDarkBackground || isDarkAppearance ? NSColor.black : NSColor.windowBackgroundColor } private var separatorColor: NSColor { - isDarkAppearance ? NSColor.white : NSColor.labelColor + prefersDarkBackground || isDarkAppearance ? NSColor.white : NSColor.labelColor } private var isDarkAppearance: Bool { @@ -236,6 +240,7 @@ final class HiddenItemsBarView: NSView { private var items: [HiddenItemsBarItem] = [] private var itemRects: [CGRect] = [] private var clickHandler: ((CGFloat) -> Void)? + var prefersDarkBackground = false var preferredContentSize: CGSize { let itemWidth = items.reduce(CGFloat(0)) { $0 + max($1.image.size.width, 1) } @@ -258,12 +263,12 @@ final class HiddenItemsBarView: NSView { super.draw(dirtyRect) let path = NSBezierPath(roundedRect: bounds, xRadius: 8, yRadius: 8) - NSColor.windowBackgroundColor.withAlphaComponent(0.92).setFill() + panelBackgroundColor.setFill() path.fill() let strokeColor: NSColor if #available(OSX 10.14, *) { - strokeColor = NSColor.separatorColor + strokeColor = prefersDarkBackground ? NSColor.white.withAlphaComponent(0.28) : NSColor.separatorColor } else { strokeColor = NSColor.lightGray } @@ -314,7 +319,7 @@ final class HiddenItemsBarView: NSView { private func drawEmptyState() { let text = "Hidden items unavailable".localized let attributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: NSColor.secondaryLabelColor, + .foregroundColor: prefersDarkBackground ? NSColor.white.withAlphaComponent(0.72) : NSColor.secondaryLabelColor, .font: NSFont.systemFont(ofSize: 12) ] let size = text.size(withAttributes: attributes) @@ -323,4 +328,12 @@ final class HiddenItemsBarView: NSView { withAttributes: attributes ) } + + private var panelBackgroundColor: NSColor { + if prefersDarkBackground { + return NSColor.black.withAlphaComponent(0.88) + } + + return NSColor.windowBackgroundColor.withAlphaComponent(0.92) + } } diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index eda593c..52edd98 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -358,7 +358,8 @@ extension StatusBarController { } self.hiddenItemsSeparatorOverlayController.show( frame: capture.menuBarOverlayFrame, - separatorFrame: capture.separatorFrame + separatorFrame: capture.separatorFrame, + prefersDarkBackground: capture.prefersDarkBackground ) if let button = self.btnExpandCollapse.button { button.image = Assets.collapseImage @@ -436,7 +437,8 @@ extension StatusBarController { for: separatorFrame, covering: [], on: screen - ) + ), + prefersDarkBackground: prefersDarkBackground(for: items) ) } } @@ -510,10 +512,39 @@ extension StatusBarController { for: capture.separatorFrame, covering: visibleCapturedRects, on: capture.screen - ) + ), + prefersDarkBackground: capture.prefersDarkBackground ) } + private func prefersDarkBackground(for items: [HiddenItemsBarItem]) -> Bool { + var luminanceTotal: CGFloat = 0 + var sampleCount: CGFloat = 0 + + for item in items { + guard let cgImage = item.image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { continue } + let bitmap = NSBitmapImageRep(cgImage: cgImage) + let xStep = max(1, bitmap.pixelsWide / 8) + let yStep = max(1, bitmap.pixelsHigh / 8) + + stride(from: 0, to: bitmap.pixelsWide, by: xStep).forEach { x in + stride(from: 0, to: bitmap.pixelsHigh, by: yStep).forEach { y in + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB), + color.alphaComponent > 0.2 + else { + return + } + + luminanceTotal += 0.2126 * color.redComponent + 0.7152 * color.greenComponent + 0.0722 * color.blueComponent + sampleCount += 1 + } + } + } + + guard sampleCount > 0 else { return false } + return (luminanceTotal / sampleCount) > 0.55 + } + private func menuBarOverlayFrame(for separatorFrame: CGRect, covering itemFrames: [CGRect], on screen: NSScreen) -> CGRect { let menuBarHeight = max(22, screen.frame.maxY - screen.visibleFrame.maxY) let minX = max( From 0852939fea06e1c199e53702e242af9ac67dcc2d Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sun, 31 May 2026 12:48:56 -0700 Subject: [PATCH 6/7] fix(statusbar): mask separate bar capture flash Let the capture shield settle before revealing hidden menu bar items for capture, and size it from the collapsed separator geometry so the full hidden section is covered instead of a fixed-width slice. --- .../HiddenItemsBarPanelController.swift | 7 +- .../StatusBar/StatusBarController.swift | 71 +++++++++++-------- 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift b/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift index a075201..868e8cf 100644 --- a/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift +++ b/hidden/Features/StatusBar/HiddenItemsBarPanelController.swift @@ -112,13 +112,14 @@ final class HiddenItemsBarCaptureShieldController: NSObject { panel.ignoresMouseEvents = true } - func show(on screen: NSScreen, near expandCollapseFrame: CGRect) { + func show(on screen: NSScreen, near expandCollapseFrame: CGRect, covering hiddenSectionFrame: CGRect?) { let menuBarHeight = max(22, screen.frame.maxY - screen.visibleFrame.maxY) let shieldPadding: CGFloat = 12 + let fallbackWidth: CGFloat = 360 let shieldFrame: CGRect if Constant.isUsingLTRLanguage { - let minX = max(screen.frame.minX, expandCollapseFrame.minX - 360) + let minX = max(screen.frame.minX, hiddenSectionFrame?.minX ?? expandCollapseFrame.minX - fallbackWidth) shieldFrame = CGRect( x: minX, y: screen.frame.maxY - menuBarHeight, @@ -126,7 +127,7 @@ final class HiddenItemsBarCaptureShieldController: NSObject { height: menuBarHeight ) } else { - let maxX = min(screen.frame.maxX, expandCollapseFrame.maxX + 360) + let maxX = min(screen.frame.maxX, hiddenSectionFrame?.maxX ?? expandCollapseFrame.maxX + fallbackWidth) shieldFrame = CGRect( x: expandCollapseFrame.maxX - shieldPadding, y: screen.frame.maxY - menuBarHeight, diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index 52edd98..b850429 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -304,6 +304,7 @@ class StatusBarController { //MARK: - Separate hidden items bar extension StatusBarController { private enum SeparateBarTiming { + static let shieldSettleDelay: DispatchTimeInterval = .milliseconds(40) static let captureDelay: DispatchTimeInterval = .milliseconds(80) static let showDelayAfterCollapse: DispatchTimeInterval = .milliseconds(160) } @@ -321,13 +322,13 @@ extension StatusBarController { guard let expandCollapseGeometry = currentExpandCollapseGeometry() else { return } timer?.invalidate() - hiddenItemsCaptureShieldController.show(on: expandCollapseGeometry.screen, near: expandCollapseGeometry.frame) - btnSeparate.length = btnHiddenLength - if let button = btnExpandCollapse.button { - button.image = Assets.collapseImage - } + hiddenItemsCaptureShieldController.show( + on: expandCollapseGeometry.screen, + near: expandCollapseGeometry.frame, + covering: btnSeparate.button?.window?.frame + ) - DispatchQueue.main.asyncAfter(deadline: .now() + SeparateBarTiming.captureDelay) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + SeparateBarTiming.shieldSettleDelay) { [weak self] in guard let self = self else { return } guard Preferences.showHiddenItemsInSeparateBar else { self.hiddenItemsCaptureShieldController.hide() @@ -335,36 +336,50 @@ extension StatusBarController { return } - guard let capture = self.captureExpandedHiddenItems() else { - self.hiddenItemsCaptureShieldController.hide() - self.expandMenubar(force: true) - return + self.btnSeparate.length = self.btnHiddenLength + if let button = self.btnExpandCollapse.button { + button.image = Assets.collapseImage } - guard self.collapseMenuBarForSeparatePanel() else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + SeparateBarTiming.showDelayAfterCollapse) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + SeparateBarTiming.captureDelay) { [weak self] in guard let self = self else { return } - self.hiddenItemsCaptureShieldController.hide() - guard Preferences.showHiddenItemsInSeparateBar && self.isBtnSeparateValidPosition else { - if !self.isBtnSeparateValidPosition { - self.restoreInlineMenuBarAfterInvalidSeparatePosition() - } + guard Preferences.showHiddenItemsInSeparateBar else { + self.hiddenItemsCaptureShieldController.hide() + self.collapseMenuBar() return } - let capture = self.captureByCoveringItemsStillVisibleInMenuBar(capture) - self.hiddenItemsBarController.show(capture: capture) { [weak self] sourceX in - self?.activateHiddenItem(atSourceX: sourceX, from: capture) + guard let capture = self.captureExpandedHiddenItems() else { + self.hiddenItemsCaptureShieldController.hide() + self.expandMenubar(force: true) + return } - self.hiddenItemsSeparatorOverlayController.show( - frame: capture.menuBarOverlayFrame, - separatorFrame: capture.separatorFrame, - prefersDarkBackground: capture.prefersDarkBackground - ) - if let button = self.btnExpandCollapse.button { - button.image = Assets.collapseImage + + guard self.collapseMenuBarForSeparatePanel() else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + SeparateBarTiming.showDelayAfterCollapse) { [weak self] in + guard let self = self else { return } + self.hiddenItemsCaptureShieldController.hide() + guard Preferences.showHiddenItemsInSeparateBar && self.isBtnSeparateValidPosition else { + if !self.isBtnSeparateValidPosition { + self.restoreInlineMenuBarAfterInvalidSeparatePosition() + } + return + } + let capture = self.captureByCoveringItemsStillVisibleInMenuBar(capture) + + self.hiddenItemsBarController.show(capture: capture) { [weak self] sourceX in + self?.activateHiddenItem(atSourceX: sourceX, from: capture) + } + self.hiddenItemsSeparatorOverlayController.show( + frame: capture.menuBarOverlayFrame, + separatorFrame: capture.separatorFrame, + prefersDarkBackground: capture.prefersDarkBackground + ) + if let button = self.btnExpandCollapse.button { + button.image = Assets.collapseImage + } + self.autoCollapseIfNeeded() } - self.autoCollapseIfNeeded() } } } From 54da9bab027388adde7c674afee9142f41088ce6 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sun, 31 May 2026 13:14:34 -0700 Subject: [PATCH 7/7] fix(statusbar): preserve configuration drags in separate bar mode Temporarily restore the real separator width while the user Cmd-drags menu bar items so macOS still has a usable drop boundary when the separate hidden-items bar preference is enabled. Force the temporary drag state back to the collapsed width after mouse-up to avoid stretched separator menus or invalid status item ordering. --- .../StatusBar/StatusBarController.swift | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index b850429..e56e578 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -32,6 +32,9 @@ class StatusBarController { private let hiddenItemsSeparatorOverlayController = HiddenItemsBarSeparatorOverlayController() private var activeExpandCollapseFrame: CGRect? private var activeStatusItemScreen: NSScreen? + private var configurationDragMonitor: Any? + private var isTemporarilyExpandedForConfigurationDrag = false + private var configurationDragCollapseWorkItem: DispatchWorkItem? private var isCollapsed: Bool { return self.btnSeparate.length == self.btnHiddenCollapseLength @@ -76,6 +79,7 @@ class StatusBarController { updateCollapsedLengths() setupUI() setupAlwayHideStatusBar() + setupConfigurationDragMonitor() NotificationCenter.default.addObserver(self, selector: #selector(handleScreenParametersChanged), name: NSApplication.didChangeScreenParametersNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handlePreferencesChanged), name: .prefsChanged, object: nil) DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { @@ -88,6 +92,9 @@ class StatusBarController { deinit { NotificationCenter.default.removeObserver(self) + if let configurationDragMonitor = configurationDragMonitor { + NSEvent.removeMonitor(configurationDragMonitor) + } } @objc private func handleScreenParametersChanged() { @@ -301,6 +308,76 @@ class StatusBarController { } } +//MARK: - Configuration drag support +extension StatusBarController { + private func setupConfigurationDragMonitor() { + configurationDragMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDragged, .leftMouseUp]) { [weak self] event in + DispatchQueue.main.async { + self?.handleConfigurationDragEvent(event) + } + } + } + + private func handleConfigurationDragEvent(_ event: NSEvent?) { + guard let event = event else { return } + + switch event.type { + case .leftMouseDragged: + guard event.modifierFlags.contains(.command) else { return } + temporarilyExpandForConfigurationDragIfNeeded() + case .leftMouseUp: + collapseAfterConfigurationDragIfNeeded() + default: + break + } + } + + private func temporarilyExpandForConfigurationDragIfNeeded() { + guard + Preferences.showHiddenItemsInSeparateBar, + !Preferences.areSeparatorsHidden, + isCollapsed + else { + return + } + + configurationDragCollapseWorkItem?.cancel() + hiddenItemsCaptureShieldController.hide() + hiddenItemsBarController.hide() + hiddenItemsSeparatorOverlayController.hide() + btnSeparate.length = btnHiddenLength + isTemporarilyExpandedForConfigurationDrag = true + + if let button = btnExpandCollapse.button { + button.image = Assets.collapseImage + } + } + + private func collapseAfterConfigurationDragIfNeeded() { + guard isTemporarilyExpandedForConfigurationDrag else { return } + + configurationDragCollapseWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.isTemporarilyExpandedForConfigurationDrag = false + self.forceCollapseAfterConfigurationDrag() + } + configurationDragCollapseWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem) + } + + private func forceCollapseAfterConfigurationDrag() { + hiddenItemsCaptureShieldController.hide() + hiddenItemsBarController.hide() + hiddenItemsSeparatorOverlayController.hide() + btnSeparate.length = btnHiddenCollapseLength + + if let button = btnExpandCollapse.button { + button.image = Assets.expandImage + } + } +} + //MARK: - Separate hidden items bar extension StatusBarController { private enum SeparateBarTiming {