// // Menu.swift // PrivadoVPN // // Created by Murad Shabanov on 05.08.2021. // Copyright © 2021 Privado LLC. All rights reserved. // import Cocoa public protocol MenuDelegate: AnyObject { func menuItemDidTap(_ index: Int?) } public final class Menu { public private(set) var items: [WindowMenuItem] public var numberOfItems: Int { return items.count } public var selectedItem: WindowMenuItem? { return items.first { $0.id == selectedId } } public weak var delegate: MenuDelegate? private var window: Window? private var lostFocusObserver: Any? private var localMonitor: EventMonitor? private let configuration: Configuration private var selectedId: UUID? private let title: String? private weak var targetView: NSView? public convenience init() { self.init(with: nil, configuration: MenuConfiguration()) } public init(with title: String?, items: [WindowMenuItem] = [WindowMenuItem](), configuration: Configuration) { self.title = title self.items = items self.configuration = configuration } // MARK: - Show and dismiss public func show(from view: NSView, animated: Bool = true, isPopover: Bool = false) { show(items, from: view, animated: animated, isPopover: isPopover) } public func dismiss(animated: Bool = true) { let actualDismiss: (NSWindow) -> Void = { [weak self] menuWindow in self?.window?.parent?.removeChildWindow(menuWindow) self?.window?.orderOut(self) self?.window = nil self?.stopMonitors() } if let menuWindow = window { if animated { fadeOut(window: menuWindow) { actualDismiss(menuWindow) } } else { actualDismiss(menuWindow) } } } // MARK: - Selection public func selectItem(at index: Int) { guard let item = item(at: index) else { return } selectedId = item.id item.action?() } // MARK: - Adding and Removing Menu Items public func insertItem(_ item: WindowMenuItem, at index: Int) { items.insert(item, at: index) } public func addItem(_ item: WindowMenuItem) { items.append(item) } public func addItems(_ items: [WindowMenuItem]) { self.items.append(contentsOf: items) } public func removeItem(at index: Int) { guard items.indices.contains(index) else { return } let deletedItem = items.remove(at: index) if deletedItem.id == selectedId { selectedId = nil } } public func removeItem(_ item: WindowMenuItem) { items.removeAll { $0.id == item.id } if item.id == selectedId { selectedId = nil } } public func removeAllItems() { items.removeAll() selectedId = nil } // MARK: - Finding Menu Items public func item(at index: Int) -> WindowMenuItem? { guard items.indices.contains(index) else { return nil } return items[index] } public func item(withTitle title: String) -> WindowMenuItem? { items.first { $0.title == title } } // MARK: - Disabling&Enabling public func disableAllItems() { items = items.map { oldItem in var newItem = oldItem newItem.isEnabled = false return newItem } } public func enableAllItems() { items = items.map { oldItem in var newItem = oldItem newItem.isEnabled = true return newItem } } // MARK: - Private private func show(_ items: [WindowMenuItem], from view: NSView, animated: Bool, isPopover: Bool) { guard window == nil, let parentWindow = view.window else { return } let menuWindow = makeWindow( with: title, menuItems: items, attachedTo: parentWindow, relativeTo: view, isPopover: isPopover ) self.window = menuWindow setupMonitors(for: parentWindow, targetView: view) if animated { fadeIn(menuWindow) } else { menuWindow.alphaValue = 1.0 } } private func fadeIn(_ window: NSWindow) { window.alphaValue = 0.0 NSAnimationContext.runAnimationGroup { context in context.duration = configuration.animationDuration context.timingFunction = CAMediaTimingFunction(name: .easeIn) window.animator().alphaValue = 1.0 } } private func fadeOut(window: NSWindow, completion: @escaping () -> Void) { NSAnimationContext.runAnimationGroup({ context in context.duration = configuration.animationDuration context.timingFunction = CAMediaTimingFunction(name: .easeOut) window.animator().alphaValue = 0.0 }, completionHandler: { completion() }) } private func setupMonitors(for parentWindow: NSWindow, targetView: NSView) { lostFocusObserver = NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, object: parentWindow, queue: nil, using: { [weak self] (_ arg1: Notification) in self?.dismiss(animated: false) self?.delegate?.menuItemDidTap(nil) }) localMonitor = EventMonitor(monitorType: .local, mask: [.leftMouseDown, .rightMouseDown, .otherMouseDown], globalHandler: nil, localHandler: { [weak self] event -> NSEvent? in guard let localEvent = event else { return event } guard let location = event?.locationInWindow else { return event } let targetViewFrame = targetView.convert(targetView.bounds, to: nil) let view = NSView(frame: targetViewFrame) if view.isMousePoint(location, in: view.frame) { return localEvent } if localEvent.window != self?.window { if localEvent.window == parentWindow { self?.dismiss(animated: true) self?.delegate?.menuItemDidTap(nil) // Ignore clicking on presenting view // let contentView = parentWindow.contentView // let locationTest = contentView?.convert(localEvent.locationInWindow, from: nil) // let hitView = contentView?.hitTest(locationTest ?? .zero) // if hitView != targetView { // self?.dismiss() // } } } return localEvent }) localMonitor?.start() } private func stopMonitors() { localMonitor?.stop() localMonitor = nil if let lostFocusObserver = lostFocusObserver { NotificationCenter.default.removeObserver(lostFocusObserver) self.lostFocusObserver = nil } } private func makeWindow(with title: String?, menuItems: [WindowMenuItem], attachedTo parentWindow: NSWindow, relativeTo targetView: NSView, isPopover: Bool) -> Window { let contentViewController = ContentViewController(with: title, menuItems: menuItems, selectedId: selectedId, configuration: configuration) contentViewController.delegate = self let window = Window.make(with: configuration) window.contentViewController = contentViewController parentWindow.addChildWindow(window, ordered: .above) setFrame(for: window, relativeTo: targetView, isPopover: isPopover) return window } private func setFrame(for window: NSWindow, relativeTo view: NSView, isPopover: Bool) { guard let parentWindow = view.window, let topMostSuperView = parentWindow.contentView else { return } let locationInWindow = view.convert(topMostSuperView.frame.origin, to: nil) let rectInWindow = NSRect(origin: locationInWindow, size: CGSize(width: NSWidth(view.frame), height: NSHeight(window.frame))) let rectInScreen = parentWindow.convertToScreen(rectInWindow) let origin = rectInScreen.origin let originX = origin.x var additionalYOffset = configuration.appearsBelowSender ? NSHeight(view.frame) : 0 additionalYOffset += abs(configuration.presentingOffset) var newFrame = NSRect(x: originX, y: origin.y - NSHeight(window.frame) - additionalYOffset, width: NSWidth(view.frame), height: NSHeight(window.frame)) if isPopover { newFrame.origin.x -= 13 newFrame.origin.y += 13 } window.setFrame(newFrame, display: true, animate: false) } } extension Menu: ContentViewControllerDelegate { func didClickMenuItem(withId id: UUID, index: Int) { selectedId = id dismiss(animated: true) delegate?.menuItemDidTap(index) } } public class EventMonitor { public enum MonitorType { case global case local } public typealias GlobalEventHandler = (NSEvent?) -> Void public typealias LocalEventHandler = (NSEvent?) -> NSEvent? private var monitor: Any? private let mask: NSEvent.EventTypeMask private let monitorType: MonitorType private let globalHandler: GlobalEventHandler? private let localHandler: LocalEventHandler? public init(monitorType: MonitorType, mask: NSEvent.EventTypeMask, globalHandler: GlobalEventHandler?, localHandler: LocalEventHandler?) { self.mask = mask self.monitorType = monitorType self.globalHandler = globalHandler self.localHandler = localHandler } deinit { stop() } public func start() { switch monitorType { case .global: startGlobalMonitor() case .local: startLocalMonitor() } } public func stop() { guard let monitor = self.monitor else { return } NSEvent.removeMonitor(monitor) self.monitor = nil } private func startGlobalMonitor() { guard let handler = globalHandler else { assertionFailure("Global event handler is not set.") return } monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) } private func startLocalMonitor() { guard let handler = localHandler else { assertionFailure("Local event handler is not set.") return } monitor = NSEvent.addLocalMonitorForEvents(matching: mask, handler: handler) } }