Files

327 lines
10 KiB
Swift

//
// 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)
}
}