Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

387 lines
12 KiB
Swift

import AppKit
import Combine
import MacToolsPluginKit
// MARK: - MenuBarHiddenDivider
//
// Owns one section-divider NSStatusItem. When `expand()` is called, the
// divider grows to `MenuBarHiddenConstants.dividerExpandedLength`, which
// pushes every status item to its LEFT off-screen. This mirrors Thaw's hidden
// and always-hidden `ControlItem` behavior.
//
// How it works (mirrors Thaw/Ice's ControlItem):
// 1. After creation, we find the NSLayoutConstraint in the status bar window
// whose `secondItem` is `button.superview` (the internal container view).
// Activating this constraint forces the window to expand to
// `dividerExpandedLength` points, overriding AutoLayout's default sizing.
// 2. We ALSO set `statusItem.length` to the same value as a belt-and-suspenders
// signal to NSStatusBar.
// 3. On collapse, we deactivate the constraint, set a visible divider width,
// and call `setContentSize` so the separator itself remains visible.
//
@MainActor
final class MenuBarHiddenDivider {
enum Kind {
case hidden
case alwaysHidden
var autosaveName: String {
switch self {
case .hidden:
MenuBarControlItemDefaults.hiddenAutosaveName
case .alwaysHidden:
MenuBarControlItemDefaults.alwaysHiddenAutosaveName
}
}
var logName: String {
switch self {
case .hidden: "hidden"
case .alwaysHidden: "always-hidden"
}
}
func prepare(preferredPosition: Double?) {
switch self {
case .hidden:
MenuBarControlItemDefaults.prepareHiddenDividerControlItem(
preferredPosition: preferredPosition
?? MenuBarControlItemDefaults.preferredPositionForHiddenDividerLeftOfVisibleControlItem()
)
case .alwaysHidden:
MenuBarControlItemDefaults.prepareAlwaysHiddenDividerControlItem()
}
}
func preferredPositionForRecovery() -> Double? {
switch self {
case .hidden:
MenuBarControlItemDefaults.preferredPositionForHiddenDividerLeftOfVisibleControlItem()
case .alwaysHidden:
nil
}
}
func setPreferredPositionForRecovery(_ position: Double?) {
switch self {
case .hidden:
MenuBarControlItemDefaults.setHiddenDividerControlItemPreferredPosition(position)
case .alwaysHidden:
break
}
}
}
/// Fires when the divider window's screen frame changes.
var onFrameChange: (() -> Void)?
/// CGWindowID of the divider's backing window (when installed).
private(set) var windowID: CGWindowID?
var hiddenControlWindowIDs: Set<CGWindowID> {
var ids = Set<CGWindowID>()
if let windowID {
ids.insert(windowID)
}
for item in spacerItems {
guard let number = item.button?.window?.windowNumber, number > 0, let id = CGWindowID(exactly: number) else {
continue
}
ids.insert(id)
}
return ids
}
private(set) var isInstalled = false
private(set) var isExpanded = false
private(set) var isVisibleDivider = false
var screenFrame: NSRect? {
guard let button = statusItem?.button, let window = button.window else { return nil }
let frameInWindow = button.convert(button.bounds, to: nil)
return window.convertToScreen(frameInWindow)
}
private let kind: Kind
private var statusItem: NSStatusItem?
private var widthConstraint: NSLayoutConstraint?
private var centerYConstraint: NSLayoutConstraint?
private var windowObserver: AnyCancellable?
private var frameObserver: AnyCancellable?
private var spacerItems: [NSStatusItem] = []
init(kind: Kind) {
self.kind = kind
}
// MARK: - Install / uninstall
func install(preferredPosition: Double? = nil) {
guard !isInstalled else { return }
kind.prepare(preferredPosition: preferredPosition)
let item = NSStatusBar.system.statusItem(withLength: 0)
item.autosaveName = kind.autosaveName
if let button = item.button {
configureButton(button)
observeWindow(for: item)
bindWindow(button.window, for: button)
}
statusItem = item
isInstalled = true
showSection(isDragging: false)
updateWindowID()
DispatchQueue.main.async { [weak self] in
self?.updateWindowID()
self?.onFrameChange?()
}
MenuBarHiddenLog.plugin.debug(
"\(self.kind.logName) divider installed windowID=\(self.windowID ?? 0) hasConstraint=\(self.widthConstraint != nil)"
)
}
func uninstall() {
guard isInstalled, let item = statusItem else { return }
frameObserver?.cancel()
frameObserver = nil
windowObserver?.cancel()
windowObserver = nil
removeSpacerItems()
NSStatusBar.system.removeStatusItem(item)
statusItem = nil
widthConstraint = nil
centerYConstraint = nil
windowID = nil
isInstalled = false
isExpanded = false
isVisibleDivider = false
MenuBarHiddenLog.plugin.debug("\(self.kind.logName) divider uninstalled")
}
func reinstall() {
let shouldHide = isExpanded
let targetPosition = kind.preferredPositionForRecovery()
kind.setPreferredPositionForRecovery(targetPosition)
uninstall()
kind.setPreferredPositionForRecovery(targetPosition)
install(preferredPosition: targetPosition)
if shouldHide {
hideSection()
} else {
showSection(isDragging: false)
}
}
func refreshWindowID() {
updateWindowID()
}
// MARK: - State
/// Expands the divider so items to its left are pushed off-screen.
func expand() {
hideSection()
MenuBarHiddenLog.plugin.debug(
"\(self.kind.logName) divider expanded (constraintActive=\(self.widthConstraint?.isActive ?? false))"
)
}
/// Shows the divider so hidden items become visible again.
func collapse() {
showSection(isDragging: false)
MenuBarHiddenLog.plugin.debug("\(self.kind.logName) divider shown")
}
func hideSection() {
updateVisibility(.hidden)
}
func showSection(isDragging: Bool) {
updateVisibility(.shown)
}
// MARK: - Helpers
private func configureButton(_ button: NSStatusBarButton) {
button.title = ""
button.image = nil
button.isEnabled = false
button.appearsDisabled = true
button.isHighlighted = false
button.alphaValue = 0
}
private func observeWindow(for item: NSStatusItem) {
windowObserver = item.publisher(for: \.button, options: [.initial, .new])
.compactMap { $0 }
.flatMap { button in
button.publisher(for: \.window, options: [.initial, .new]).map { window in (button, window) }
}
.receive(on: DispatchQueue.main)
.sink { [weak self] button, window in
self?.bindWindow(window, for: button)
}
}
private func bindWindow(_ window: NSWindow?, for button: NSStatusBarButton) {
guard let window else {
frameObserver?.cancel()
frameObserver = nil
updateWindowID()
return
}
if widthConstraint == nil {
widthConstraint = findWidthConstraint(for: button)
}
widthConstraint?.isActive = isExpanded
if centerYConstraint == nil, let contentView = window.contentView {
let constraint = button.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
constraint.isActive = true
centerYConstraint = constraint
}
frameObserver?.cancel()
frameObserver = window.publisher(for: \.frame)
.debounce(for: .milliseconds(80), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateWindowID()
self?.onFrameChange?()
}
updateWindowID()
}
private func findWidthConstraint(for button: NSStatusBarButton) -> NSLayoutConstraint? {
guard let contentView = button.window?.contentView else {
return nil
}
let container = button.superview
return contentView
.constraintsAffectingLayout(for: .horizontal)
.first { $0.secondItem === container }
}
private func updateVisibility(_ state: State) {
guard isInstalled, let item = statusItem else { return }
configureButtonForState(state)
if state == .hidden {
widthConstraint?.isActive = true
item.length = MenuBarHiddenConstants.dividerExpandedLength
updateSpacerItems()
isExpanded = true
isVisibleDivider = false
} else {
removeSpacerItems()
widthConstraint?.isActive = false
item.length = MenuBarHiddenConstants.dividerVisibleLength
shrinkWindow(for: item, width: state.windowWidth)
isExpanded = false
isVisibleDivider = true
}
updateWindowID()
DispatchQueue.main.async { [weak self] in
self?.updateWindowID()
self?.onFrameChange?()
}
}
private func configureButtonForState(_ state: State) {
guard let button = statusItem?.button else { return }
button.image = nil
button.isHighlighted = false
button.title = state == .shown ? "|" : ""
button.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
switch state {
case .hidden:
button.isEnabled = false
button.appearsDisabled = true
button.alphaValue = 0
case .shown:
button.isEnabled = true
button.appearsDisabled = false
button.alphaValue = 1
}
}
private func shrinkWindow(for item: NSStatusItem, width: CGFloat) {
guard let window = item.button?.window else { return }
var size = window.frame.size
size.width = width
window.setContentSize(size)
}
private func updateSpacerItems() {
let needed = requiredSpacerCount()
guard needed > 0 else {
removeSpacerItems()
return
}
if spacerItems.count != needed {
removeSpacerItems()
spacerItems = (0..<needed).map { index in
let item = NSStatusBar.system.statusItem(withLength: 0)
item.autosaveName = "\(kind.autosaveName).Spacer.\(index)"
if let button = item.button {
configureButton(button)
}
return item
}
}
spacerItems.forEach { $0.length = MenuBarHiddenConstants.dividerExpandedLength }
}
private func removeSpacerItems() {
for item in spacerItems {
NSStatusBar.system.removeStatusItem(item)
}
spacerItems.removeAll()
}
private func requiredSpacerCount() -> Int {
let maxScreenWidth = NSScreen.screens.map(\.frame.width).max() ?? 6000
guard maxScreenWidth > 5120 else { return 0 }
let desiredWidth = maxScreenWidth * 3
let remaining = desiredWidth - MenuBarHiddenConstants.dividerExpandedLength
guard remaining > 0 else { return 0 }
return Int(ceil(remaining / MenuBarHiddenConstants.dividerExpandedLength))
}
private func updateWindowID() {
guard let window = statusItem?.button?.window else {
windowID = nil
return
}
let number = window.windowNumber
guard number > 0, let id = CGWindowID(exactly: number) else {
windowID = nil
return
}
windowID = id
}
private enum State {
case hidden
case shown
var windowWidth: CGFloat {
switch self {
case .hidden:
MenuBarHiddenConstants.dividerExpandedLength
case .shown:
MenuBarHiddenConstants.dividerVisibleLength
}
}
}
}