diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..54782e3 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/Package.swift b/Package.swift index 74c6ce4..82e4a46 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,9 @@ import PackageDescription let package = Package( name: "Popover", + platforms: [ + .macOS(.v10_14) + ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( diff --git a/Sources/Popover/Popover.swift b/Sources/Popover/Popover.swift index efe500b..d5def4e 100644 --- a/Sources/Popover/Popover.swift +++ b/Sources/Popover/Popover.swift @@ -1,3 +1,63 @@ -struct Popover { - var text = "Hello, World!" +// +// Popover.swift +// Popover +// +// Created by Ivan Sapozhnik on 29.03.20. +// Copyright © 2020 heavylightapps. All rights reserved. +// + +import Cocoa + +public class Popover: NSObject { + enum PopoverPresentationMode { + case undefined + case image + case customView + } + + public var item: NSStatusItem! + + private var popoverWindowController: PopoverWindowController? + private let windowConfiguration: PopoverConfiguration + private var observation: NSKeyValueObservation? + + private var isPopoverWindowVisible: Bool { + return (popoverWindowController != nil) ? popoverWindowController!.windowIsOpen : false + } + + public init(with configuration: PopoverConfiguration) { + windowConfiguration = configuration + } + + public func presentPopover(with image: NSImage, contentViewController viewController: NSViewController) { + configureStatusBarButton(with: image) + popoverWindowController = PopoverWindowController(with: self, contentViewController: viewController, windowConfiguration: windowConfiguration) + } + + private func configureStatusBarButton(with image: NSImage) { + image.isTemplate = true + + item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + guard let button = item.button else { return } + + button.target = self + button.action = #selector(handleStatusItemButtonAction(_:)) + button.image = image + } + + @objc private func handleStatusItemButtonAction(_ sender: Any?) { + if isPopoverWindowVisible { + dismissPopoverWindow() + } else { + showPopoverWindow() + } + } + + public func dismissPopoverWindow() { + popoverWindowController?.dismiss() + } + + public func showPopoverWindow() { + popoverWindowController?.show() + } } diff --git a/Sources/Popover/PopoverConfiguration.swift b/Sources/Popover/PopoverConfiguration.swift new file mode 100644 index 0000000..a5a5fc9 --- /dev/null +++ b/Sources/Popover/PopoverConfiguration.swift @@ -0,0 +1,56 @@ +// +// WindowConfiguration.swift +// Popover +// +// Created by Ivan Sapozhnik on 29.03.20. +// Copyright © 2020 heavylightapps. All rights reserved. +// + +import Cocoa + +public protocol PopoverConfiguration { + var windowToPopoverMargin: CGFloat { get } + var backgroundColor: NSColor { get } + var lineColor: NSColor? { get } + var lineWidth: CGFloat { get } + var arrowHeight: CGFloat { get } + var arrowWidth: CGFloat { get } + var cornerRadius: CGFloat { get } + var contentInset: NSEdgeInsets { get } +} + +public class DefaultConfiguration: PopoverConfiguration { + public init() {} + + public var windowToPopoverMargin: CGFloat { + return 22 + } + + public var backgroundColor: NSColor { + return NSColor.windowBackgroundColor//NSColor(calibratedRed: 0.213, green: 0.213, blue: 0.213, alpha: 1.0) + } + + public var lineColor: NSColor? { + return nil//NSColor(calibratedRed: 0.413, green: 0.413, blue: 0.413, alpha: 1.0) + } + + public var lineWidth: CGFloat { + return lineColor != nil ? 6 : 0 + } + + public var arrowHeight: CGFloat { + return 11.0 + } + + public var arrowWidth: CGFloat { + return 62.0 + } + + public var cornerRadius: CGFloat { + return 20.0 + } + + public var contentInset: NSEdgeInsets { + return NSEdgeInsets(top: 0, left: 0.0, bottom: 0, right: 0.0) + } +} diff --git a/Sources/Popover/PopoverWindow.swift b/Sources/Popover/PopoverWindow.swift new file mode 100644 index 0000000..f47d524 --- /dev/null +++ b/Sources/Popover/PopoverWindow.swift @@ -0,0 +1,88 @@ +// +// PopoverWindow.swift +// Popover +// +// Created by Ivan Sapozhnik on 29.03.20. +// Copyright © 2020 heavylightapps. All rights reserved. +// + +import Cocoa + +public class PopoverWindow: NSPanel { + private let windowConfiguration: PopoverConfiguration + private var childContentView: NSView? + private var backgroundView: PopoverWindowBackgroundView? + + public static func window(with configuration: PopoverConfiguration) -> PopoverWindow { + + let window = PopoverWindow.init(contentRect: .zero, styleMask: .nonactivatingPanel, backing: .buffered, defer: true, configuration: configuration) + + return window + } + + public override var canBecomeKey: Bool { + return true + } + + public override var contentView: NSView? { + 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: windowConfiguration) + backgroundView?.layer?.edgeAntialiasingMask = antialiasingMask + super.contentView = backgroundView + } + + if (self.childContentView != nil) { + self.childContentView?.removeFromSuperview() + } + + childContentView = newValue + childContentView?.translatesAutoresizingMaskIntoConstraints = false + childContentView?.wantsLayer = true + childContentView?.layer?.cornerRadius = windowConfiguration.cornerRadius + childContentView?.layer?.masksToBounds = true + childContentView?.layer?.edgeAntialiasingMask = antialiasingMask + + guard let userContentView = self.childContentView, let backgroundView = self.backgroundView else { return } + backgroundView.addSubview(userContentView) + + let left = windowConfiguration.lineWidth + windowConfiguration.contentInset.left + let right = windowConfiguration.lineWidth + windowConfiguration.contentInset.right + let top = windowConfiguration.arrowHeight + windowConfiguration.lineWidth + windowConfiguration.contentInset.top + let bottom = windowConfiguration.lineWidth + windowConfiguration.contentInset.bottom + + NSLayoutConstraint.activate([ + userContentView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor, constant: left), + userContentView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor, constant: -right), + userContentView.topAnchor.constraint(equalTo: backgroundView.topAnchor, constant: top), + userContentView.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor, constant: -bottom) + ]) + } + + get { + childContentView + } + } + + public override func frameRect(forContentRect contentRect: NSRect) -> NSRect { + NSMakeRect(NSMinX(contentRect), NSMinY(contentRect), NSWidth(contentRect), NSHeight(contentRect) + windowConfiguration.arrowHeight) + } + + private init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool, configuration: PopoverConfiguration) { + windowConfiguration = configuration + super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) + + isOpaque = false + hasShadow = true + level = .statusBar + animationBehavior = .utilityWindow + backgroundColor = .clear + collectionBehavior = [.canJoinAllSpaces, .ignoresCycle] + appearance = NSAppearance.current + } +} diff --git a/Sources/Popover/PopoverWindowBackgroundView.swift b/Sources/Popover/PopoverWindowBackgroundView.swift new file mode 100644 index 0000000..bb115b1 --- /dev/null +++ b/Sources/Popover/PopoverWindowBackgroundView.swift @@ -0,0 +1,83 @@ +// +// PopoverWindowBackgroundView.swift +// Popover +// +// Created by Ivan Sapozhnik on 29.03.20. +// Copyright © 2020 heavylightapps. All rights reserved. +// + +import Cocoa + +class PopoverWindowBackgroundView: NSView { + private let windowConfiguration: PopoverConfiguration + + init(frame frameRect: NSRect, windowConfiguration: PopoverConfiguration) { + self.windowConfiguration = windowConfiguration + super.init(frame: frameRect) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var lineWidth: CGFloat { + return windowConfiguration.lineColor != nil ? windowConfiguration.lineWidth : 0 + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + // Drawing code here. + + let backgroundRect = NSMakeRect(NSMinX(bounds), NSMinY(bounds), NSWidth(bounds), NSHeight(bounds) - windowConfiguration.arrowHeight).insetBy(dx: lineWidth, dy: lineWidth) + + let controlPoints = NSBezierPath() + let windowPath = NSBezierPath() + let arrowPath = NSBezierPath() + let backgroundPath = NSBezierPath(roundedRect: backgroundRect, xRadius: windowConfiguration.cornerRadius, yRadius: windowConfiguration.cornerRadius) + + let leftPoint = NSPoint(x: NSWidth(backgroundRect) / 2 - windowConfiguration.arrowWidth / 2, y: NSMaxY(backgroundRect)) + let toPoint = NSPoint(x: NSWidth(backgroundRect) / 2, y: NSMaxY(backgroundRect) + windowConfiguration.arrowHeight) + let rightPoint = NSPoint(x: NSWidth(backgroundRect) / 2 + windowConfiguration.arrowWidth / 2, y: NSMaxY(backgroundRect)) + let cp11 = NSPoint(x: NSWidth(backgroundRect) / 2 - windowConfiguration.arrowWidth / 6, y: NSMaxY(backgroundRect)) + let cp12 = NSPoint(x: NSWidth(backgroundRect) / 2 - windowConfiguration.arrowWidth / 9, y: NSMaxY(backgroundRect) + windowConfiguration.arrowHeight) + let cp21 = NSPoint(x: NSWidth(backgroundRect) / 2 + windowConfiguration.arrowWidth / 9, y: NSMaxY(backgroundRect) + windowConfiguration.arrowHeight) + let cp22 = NSPoint(x: NSWidth(backgroundRect) / 2 + windowConfiguration.arrowWidth / 6, y: NSMaxY(backgroundRect)) + + controlPoints.append(NSBezierPath(ovalIn: NSMakeRect(leftPoint.x - 2, leftPoint.y - 2, 4, 4))) + controlPoints.append(NSBezierPath(ovalIn: NSMakeRect(toPoint.x - 2, toPoint.y - 2, 4, 4))) + controlPoints.append(NSBezierPath(ovalIn: NSMakeRect(rightPoint.x - 2, rightPoint.y - 2, 4, 4))) + controlPoints.append(NSBezierPath(ovalIn: NSMakeRect(cp11.x - 2, cp11.y - 2, 4, 4))) + controlPoints.append(NSBezierPath(ovalIn: NSMakeRect(cp12.x - 2, cp12.y - 2, 4, 4))) + controlPoints.append(NSBezierPath(ovalIn: NSMakeRect(cp21.x - 2, cp21.y - 2, 4, 4))) + controlPoints.append(NSBezierPath(ovalIn: NSMakeRect(cp22.x - 2, cp22.y - 2, 4, 4))) + + arrowPath.move(to: leftPoint) + arrowPath.curve(to: toPoint, controlPoint1: cp11, controlPoint2: cp12) + arrowPath.curve(to: rightPoint, controlPoint1: cp21, controlPoint2: cp22) + arrowPath.line(to: leftPoint) + arrowPath.close() + + windowPath.append(arrowPath) + windowPath.append(backgroundPath) + + if let lineColor = windowConfiguration.lineColor { + lineColor.setStroke() + windowPath.lineWidth = windowConfiguration.lineWidth + windowPath.stroke() + } + + + windowConfiguration.backgroundColor.setFill() + windowPath.fill() + +// controlPoints.fill() + + } + + override var frame: NSRect { + didSet { + setNeedsDisplay(frame) + } + } +} diff --git a/Sources/Popover/PopoverWindowController.swift b/Sources/Popover/PopoverWindowController.swift new file mode 100644 index 0000000..791ec02 --- /dev/null +++ b/Sources/Popover/PopoverWindowController.swift @@ -0,0 +1,59 @@ +// +// PopoverWindowController.swift +// Popover +// +// Created by Ivan Sapozhnik on 29.03.20. +// Copyright © 2020 heavylightapps. All rights reserved. +// + +import Cocoa + +class PopoverWindowController: NSWindowController { + public private(set) var windowIsOpen: Bool = false + public private(set) var isAnimating: Bool = false + + private let popover: Popover + private let windowConfiguration: PopoverConfiguration + + init(with popover: Popover, contentViewController: NSViewController, windowConfiguration: PopoverConfiguration) { + self.popover = popover + self.windowConfiguration = windowConfiguration + + super.init(window: PopoverWindow.window(with: windowConfiguration)) + self.contentViewController = contentViewController + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func show() { + guard !isAnimating else { return } + + updateWindwFrame() + window?.alphaValue = 1.0 + showWindow(nil) + windowIsOpen = true + window?.makeKey() + // TODO: animation + } + + public func dismiss() { + guard !isAnimating else { return } + + window?.orderOut(self) + window?.close() + windowIsOpen = false + } + + private func updateWindwFrame() { + guard let popoverRect = popover.item.button?.window?.frame, let window = window else { return } + let x = NSMinX(popoverRect) - NSWidth(window.frame)/2 + NSWidth(popoverRect)/2 + windowConfiguration.lineWidth + let y = min(NSMinY(popoverRect), NSScreen.main!.frame.size.height - NSHeight(window.frame) - windowConfiguration.windowToPopoverMargin) + + let windowFrame = NSMakeRect(x, y, NSWidth(window.frame), NSHeight(window.frame)) + window.setFrame(windowFrame, display: true) + window.appearance = NSAppearance.current + } + +} diff --git a/Tests/PopoverTests/PopoverTests.swift b/Tests/PopoverTests/PopoverTests.swift index c8f3e35..e51de6c 100644 --- a/Tests/PopoverTests/PopoverTests.swift +++ b/Tests/PopoverTests/PopoverTests.swift @@ -6,7 +6,6 @@ final class PopoverTests: XCTestCase { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct // results. - XCTAssertEqual(Popover().text, "Hello, World!") } static var allTests = [