Merge pull request #4 from iSapozhnik/NSMenu

NSMenu and rightClicks support
This commit is contained in:
Sapozhnik Ivan 2020-04-07 20:55:50 +02:00 committed by GitHub
commit 34fb2f3444
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 209 additions and 41 deletions

View File

@ -8,13 +8,25 @@
import Cocoa
class EventMonitor {
enum MonitorType {
case global
case local
}
typealias GlobalEventHandler = (NSEvent?) -> Void
typealias LocalEventHandler = (NSEvent?) -> NSEvent?
private var monitor: Any?
private let mask: NSEvent.EventTypeMask
private let handler: (NSEvent?) -> Void
private let monitorType: MonitorType
private let globalHandler: GlobalEventHandler?
private let localHandler: LocalEventHandler?
init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) {
init(monitorType: MonitorType, mask: NSEvent.EventTypeMask, globalHandler: GlobalEventHandler?, localHandler: LocalEventHandler?) {
self.mask = mask
self.handler = handler
self.monitorType = monitorType
self.globalHandler = globalHandler
self.localHandler = localHandler
}
deinit {
@ -22,7 +34,12 @@ class EventMonitor {
}
func start() {
monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler)
switch monitorType {
case .global:
startGlobalMonitor()
case .local:
startLocalMonitor()
}
}
func stop() {
@ -31,4 +48,20 @@ class EventMonitor {
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)
}
}

View File

@ -0,0 +1,20 @@
//
// File.swift
//
//
// Created by Ivan Sapozhnik on 06.04.20.
//
import Cocoa
extension CAEdgeAntialiasingMask {
static var all: CAEdgeAntialiasingMask {
return [.layerLeftEdge, .layerRightEdge, .layerBottomEdge, .layerTopEdge]
}
}
extension Array {
var isNotEmpty: Bool {
return !isEmpty
}
}

View File

@ -0,0 +1,36 @@
//
// File.swift
//
//
// Created by Ivan Sapozhnik on 07.04.20.
//
import Foundation
public extension Popover {
enum MenuItemType {
case item(MenuItem)
case separator
}
class MenuItem: NSObject {
let title: String
let key: String
let _action: () -> Void
public convenience init(title: String, action: @escaping () -> Void) {
self.init(title: title, key: "", action: action)
}
public init(title: String, key: String, action: @escaping () -> Void) {
self.title = title
self.key = key
_action = action
}
@objc
func action() {
_action()
}
}
}

View File

@ -8,59 +8,105 @@
import Cocoa
public class Popover: NSObject {
var item: NSStatusItem!
var item: NSStatusItem! {
didSet {
prepareMenu()
}
}
private let wConfig: PopoverConfiguration
private var popoverWindowController: PopoverWindowController?
private var statusItemContainer: StatusItemContainerView?
private var eventMonitor: EventMonitor?
private var globalEventMonitor: EventMonitor?
private var localEventMonitor: EventMonitor?
private var menu: NSMenu?
private var menuItems: [MenuItemType]?
private var isPopoverWindowVisible: Bool {
return (popoverWindowController != nil) ? popoverWindowController!.windowIsOpen : false
}
/// Creates a Popover with given PopoverConfiguration. Popover configuration can be either `DefaultConfiguration` or you can sublclass this class and override some of the properties.
/// By default Popover is using Event Monitor and by doing left or right click ouside of the Popover's frame, the Popover will be automatically dismissed.
public init(with windowConfiguration: PopoverConfiguration) {
wConfig = windowConfiguration
super.init()
eventMonitor = EventMonitor(mask: [.rightMouseDown, .leftMouseDown], handler: { [weak self] event in
guard let self = self else { return }
self.dismiss()
})
/// Convenience initializer which is using default configuration
public convenience override init() {
self.init(with: DefaultConfiguration(), menuItems: nil)
}
/// Convenience initializer which is using default configuration
public convenience override init() {
self.init(with: DefaultConfiguration())
///
/// - Parameters:
/// - menuItems: Array of items that NSMenu will show
public convenience init(with menuItems: [MenuItemType]?) {
self.init(with: DefaultConfiguration(), menuItems: menuItems)
}
/// Creates a Popover with given PopoverConfiguration. Popover configuration can be either `DefaultConfiguration` or you can sublclass this class and override some of the properties.
///
/// By default Popover is using Event Monitor and by doing left or right click ouside of the Popover's frame, the Popover will be automatically dismissed.
///
/// - Parameters:
/// - windowConfiguration: Popover window configuration. By default `DefaultConfiguration` instance is being used.
/// - menuItems: Array of items that NSMenu will show
public init(with windowConfiguration: PopoverConfiguration, menuItems: [MenuItemType]?) {
wConfig = windowConfiguration
self.menuItems = menuItems
super.init()
setupMonitors()
}
/// Creates a Popover with given image and contentViewController
///
/// By default it won't present any Popover until the user clicks on status bar item
public func createPopover(with image: NSImage, contentViewController viewController: NSViewController) {
public func prepare(with image: NSImage, contentViewController viewController: NSViewController) {
configureStatusBarButton(with: image)
popoverWindowController = PopoverWindowController(with: self, contentViewController: viewController, windowConfiguration: wConfig)
localEventMonitor?.start()
}
/// Creates a Popover with given view and contentViewController. The view's height will be scaled down to 18pt and also will be vertically aligned. The wdith can be any.
///
/// By default it won't present any Popover until the user clicks on status bar item
public func createPopover(with view: NSView, contentViewController viewController: NSViewController) {
public func prepare(with view: NSView, contentViewController viewController: NSViewController) {
configureStatusBarButton(with: view)
popoverWindowController = PopoverWindowController(with: self, contentViewController: viewController, windowConfiguration: wConfig)
localEventMonitor?.start()
}
/// Shows the Popover with no animation
public func show() {
guard !isPopoverWindowVisible else { return }
popoverWindowController?.show()
eventMonitor?.start()
globalEventMonitor?.start()
}
/// Dismisses the Popover with default animation. Animation behaviour is `AnimationBehavior.utilityWindow`
/// Dismisses the Popover with default animation. Animation behavior is `AnimationBehavior.utilityWindow`
public func dismiss() {
guard isPopoverWindowVisible else { return }
popoverWindowController?.dismiss()
eventMonitor?.stop()
globalEventMonitor?.stop()
}
private func setupMonitors() {
globalEventMonitor = EventMonitor(monitorType: .global, mask: [.leftMouseDown, .rightMouseDown], globalHandler: { [weak self] _ in
guard let self = self else { return }
self.dismiss()
}, localHandler: nil)
if menuItems != nil, menuItems?.isNotEmpty ?? false {
localEventMonitor = EventMonitor(monitorType: .local, mask: [.leftMouseDown, .rightMouseDown], globalHandler: nil, localHandler: { [weak self] event -> NSEvent? in
guard let self = self, let currentEvent = event else { return event }
let isRightMouseClick = currentEvent.type == .rightMouseDown
let isControlLeftClick = currentEvent.type == .leftMouseDown && currentEvent.modifierFlags.contains(.control)
if isRightMouseClick || isControlLeftClick {
self.dismiss()
self.item.menu = self.menu
}
return event
})
}
}
private func configureStatusBarButton(with image: NSImage) {
@ -68,9 +114,8 @@ public class Popover: NSObject {
guard let button = item.button else { return }
image.isTemplate = true
button.target = self
button.action = #selector(handleStatusItemButtonAction(_:))
button.image = image
setTargetAction(for: button)
}
private func configureStatusBarButton(with view: NSView) {
@ -79,17 +124,43 @@ public class Popover: NSObject {
let statusItemContainer = StatusItemContainerView()
button.target = self
button.action = #selector(handleStatusItemButtonAction(_:))
setTargetAction(for: button)
button.addSubview(statusItemContainer)
statusItemContainer.embed(view)
button.frame = statusItemContainer.frame
self.statusItemContainer = statusItemContainer
}
@objc private func handleStatusItemButtonAction(_ sender: Any?) {
isPopoverWindowVisible ? dismiss() : show()
}
private func setTargetAction(for button: NSButton) {
button.target = self
button.action = #selector(handleStatusItemButtonAction(_:))
}
private func prepareMenu() {
guard menuItems != nil, menuItems?.isNotEmpty ?? false else { return }
let menu = NSMenu()
menuItems?.forEach { menuItemType in
switch menuItemType {
case .item(let actionItem):
let menuItem = NSMenuItem(title: actionItem.title, action: #selector(MenuItem.action), keyEquivalent: actionItem.key)
menuItem.target = actionItem
menu.addItem(menuItem)
case .separator:
menu.addItem(NSMenuItem.separator())
}
}
menu.delegate = self
self.menu = menu
}
}
extension Popover: NSMenuDelegate {
public func menuDidClose(_ menu: NSMenu) {
item.menu = nil
}
}

View File

@ -17,9 +17,20 @@ class PopoverWindow: NSPanel {
private var childContentView: NSView?
private var backgroundView: PopoverWindowBackgroundView?
private init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool, configuration: PopoverConfiguration) {
private init(
contentRect: NSRect,
styleMask style: NSWindow.StyleMask,
backing backingStoreType: NSWindow.BackingStoreType,
defer flag: Bool,
configuration: PopoverConfiguration
) {
wConfig = configuration
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
super.init(
contentRect: contentRect,
styleMask: style,
backing: backingStoreType,
defer: flag
)
isOpaque = false
hasShadow = true
@ -50,12 +61,10 @@ class PopoverWindow: NSPanel {
set {
guard childContentView != newValue, let bounds = newValue?.bounds else { return }
let antialiasingMask: CAEdgeAntialiasingMask = [.layerLeftEdge, .layerRightEdge, .layerBottomEdge, .layerTopEdge]
backgroundView = super.contentView as? PopoverWindowBackgroundView
if (backgroundView == nil) {
backgroundView = PopoverWindowBackgroundView(frame: bounds, windowConfiguration: wConfig)
backgroundView?.layer?.edgeAntialiasingMask = antialiasingMask
backgroundView?.layer?.edgeAntialiasingMask = .all
super.contentView = backgroundView
}
@ -68,7 +77,7 @@ class PopoverWindow: NSPanel {
childContentView?.wantsLayer = true
childContentView?.layer?.cornerRadius = wConfig.cornerRadius
childContentView?.layer?.masksToBounds = true
childContentView?.layer?.edgeAntialiasingMask = antialiasingMask
childContentView?.layer?.edgeAntialiasingMask = .all
guard let userContentView = self.childContentView, let backgroundView = self.backgroundView else { return }
backgroundView.addSubview(userContentView)
@ -97,7 +106,7 @@ class PopoverWindow: NSPanel {
}
private func updateArrowPosition() {
guard let backgroundView = self.backgroundView as? PopoverWindowBackgroundView else { return }
guard let backgroundView = self.backgroundView else { return }
backgroundView.arrowXLocation = arrowXLocation
}
}

View File

@ -82,5 +82,4 @@ class PopoverWindowController: NSWindowController {
return currentScreen ?? NSScreen.main ?? NSScreen()
}
}

View File

@ -24,7 +24,7 @@ class StatusItemContainerView: NSView {
)
let contentFrame = NSRect(
x: 0,
y: (Constants.maxContainerSize-Constants.maxContentHeight) / 2,
y: (Constants.maxContainerSize - Constants.maxContentHeight) / 2,
width: NSWidth(view.bounds),
height: Constants.maxContentHeight
)