327 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|