// // popup.swift // Kit // // Created by Serhiy Mytrovtsiy on 11/04/2020. // Using Swift 5.0. // Running on macOS 10.15. // // Copyright © 2020 Serhiy Mytrovtsiy. All rights reserved. // import Cocoa public protocol Popup_p: NSView { var sizeCallback: ((NSSize) -> Void)? { get set } func settings() -> NSView? func appear() func disappear() } open class PopupWrapper: NSStackView, Popup_p { open var sizeCallback: ((NSSize) -> Void)? = nil open func settings() -> NSView? { return nil } open func appear() {} open func disappear() {} } public class PopupWindow: NSWindow, NSWindowDelegate { private let viewController: PopupViewController = PopupViewController() internal var locked: Bool = false internal var openedBy: widget_t? = nil public init(title: String, view: Popup_p?, visibilityCallback: @escaping (_ state: Bool) -> Void) { self.viewController.setup(title: title, view: view) super.init( contentRect: NSRect( x: 0, y: 0, width: self.viewController.view.frame.width, height: self.viewController.view.frame.height ), styleMask: [.titled, .fullSizeContentView], backing: .buffered, defer: true ) self.viewController.visibilityCallback = { [weak self] state in self?.locked = false visibilityCallback(state) } self.contentViewController = self.viewController self.titlebarAppearsTransparent = true self.animationBehavior = .default self.collectionBehavior = .moveToActiveSpace self.backgroundColor = .clear self.hasShadow = true self.setIsVisible(false) self.delegate = self } public func windowWillMove(_ notification: Notification) { self.viewController.setCloseButton(true) self.locked = true } public func windowDidResignKey(_ notification: Notification) { if self.locked { return } self.viewController.setCloseButton(false) self.setIsVisible(false) } } internal class PopupViewController: NSViewController { public var visibilityCallback: (_ state: Bool) -> Void = {_ in } private var popup: PopupView public init() { self.popup = PopupView(frame: NSRect( x: 0, y: 0, width: Constants.Popup.width + (Constants.Popup.margins * 2), height: Constants.Popup.height+Constants.Popup.headerHeight )) super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { self.view = self.popup } override func viewWillAppear() { super.viewWillAppear() self.popup.appear() self.visibilityCallback(true) } override func viewWillDisappear() { super.viewWillDisappear() self.popup.disappear() self.visibilityCallback(false) } public func setup(title: String, view: Popup_p?) { self.title = title self.popup.setTitle(title) self.popup.setView(view) } public func setCloseButton(_ state: Bool) { self.popup.setCloseButton(state) } } internal class PopupView: NSView { private var title: String? = nil private var view: Popup_p? = nil private var foreground: NSVisualEffectView private var background: NSView private let header: HeaderView private let body: NSScrollView override var intrinsicContentSize: CGSize { return CGSize(width: self.frame.width, height: self.frame.height) } private var windowHeight: CGFloat? private var containerHeight: CGFloat? override init(frame: NSRect) { self.header = HeaderView(frame: NSRect( x: 0, y: frame.height - Constants.Popup.headerHeight, width: frame.width, height: Constants.Popup.headerHeight )) self.body = NSScrollView(frame: NSRect( x: Constants.Popup.margins, y: Constants.Popup.margins, width: frame.width - Constants.Popup.margins*2, height: frame.height - self.header.frame.height - Constants.Popup.margins*2 )) self.windowHeight = NSScreen.main?.visibleFrame.height self.containerHeight = self.body.documentView?.frame.height self.foreground = NSVisualEffectView(frame: frame) self.foreground.material = .titlebar self.foreground.blendingMode = .behindWindow self.foreground.state = .active self.foreground.wantsLayer = true self.foreground.layer?.backgroundColor = NSColor.red.cgColor self.foreground.layer?.cornerRadius = 6 self.background = NSView(frame: frame) self.background.wantsLayer = true self.foreground.addSubview(self.background) super.init(frame: frame) self.body.drawsBackground = false self.body.translatesAutoresizingMaskIntoConstraints = true self.body.borderType = .noBorder self.body.hasVerticalScroller = true self.body.hasHorizontalScroller = false self.body.autohidesScrollers = true self.body.horizontalScrollElasticity = .none self.addSubview(self.foreground, positioned: .below, relativeTo: .none) self.addSubview(self.header) self.addSubview(self.body) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func updateLayer() { self.background.layer?.backgroundColor = self.isDarkMode ? .clear : NSColor.white.cgColor } public func setView(_ view: Popup_p?) { self.view = view var isScrollVisible: Bool = false var size: NSSize = NSSize( width: (view?.frame.width ?? Constants.Popup.width) + (Constants.Popup.margins*2), height: (view?.frame.height ?? 0) + Constants.Popup.headerHeight + (Constants.Popup.margins*2) ) self.windowHeight = NSScreen.main?.visibleFrame.height // for height recalculate when appear/disappear self.containerHeight = self.body.documentView?.frame.height // for scroll diff calculation if let screenHeight = NSScreen.main?.visibleFrame.height, size.height > screenHeight { size.height = screenHeight - Constants.Widget.height isScrollVisible = true } if let screenWidth = NSScreen.main?.visibleFrame.width, size.width > screenWidth { size.width = screenWidth } self.setFrameSize(size) self.foreground.setFrameSize(size) self.background.setFrameSize(size) self.body.setFrameSize(NSSize( width: size.width - (Constants.Popup.margins*2) + (isScrollVisible ? 20 : 0), height: size.height - Constants.Popup.headerHeight - (Constants.Popup.margins*2) )) self.header.setFrameOrigin(NSPoint(x: 0, y: size.height - Constants.Popup.headerHeight)) if let view = view { self.body.documentView = view view.sizeCallback = { [weak self] size in self?.recalculateHeight(size) } } } public func setTitle(_ newTitle: String) { self.title = newTitle self.header.setTitle(newTitle) } public func setCloseButton(_ state: Bool) { self.header.setCloseButton(state) } internal func appear() { self.display() self.body.subviews.first?.display() if let screenHeight = NSScreen.main?.visibleFrame.height, let size = self.body.documentView?.frame.size { if screenHeight != self.windowHeight { self.recalculateHeight(size) } } if let documentView = self.body.documentView { documentView.scroll(NSPoint(x: 0, y: documentView.bounds.size.height)) } self.view?.appear() } internal func disappear() { self.header.setCloseButton(false) self.view?.disappear() } private func recalculateHeight(_ size: NSSize) { var isScrollVisible: Bool = false var windowSize: NSSize = NSSize( width: size.width + (Constants.Popup.margins*2), height: size.height + Constants.Popup.headerHeight + (Constants.Popup.margins*2) ) let h0 = self.containerHeight ?? 0 self.windowHeight = NSScreen.main?.visibleFrame.height // for height recalculate when appear/disappear self.containerHeight = self.body.documentView?.frame.height // for scroll diff calculation if let screenHeight = NSScreen.main?.visibleFrame.height, windowSize.height > screenHeight { windowSize.height = screenHeight - Constants.Widget.height isScrollVisible = true } if let screenWidth = NSScreen.main?.visibleFrame.width, windowSize.width > screenWidth { windowSize.width = screenWidth } self.window?.setContentSize(windowSize) self.foreground.setFrameSize(windowSize) self.background.setFrameSize(windowSize) self.body.setFrameSize(NSSize( width: windowSize.width - (Constants.Popup.margins*2) + (isScrollVisible ? 20 : 0), height: windowSize.height - Constants.Popup.headerHeight - (Constants.Popup.margins*2) )) self.header.setFrameOrigin(NSPoint( x: self.header.frame.origin.x, y: self.body.frame.height + (Constants.Popup.margins*2) )) if let documentView = self.body.documentView { let diff = h0 - (self.body.documentView?.frame.height ?? 0) documentView.scroll(NSPoint( x: 0, y: self.body.documentVisibleRect.origin.y - (diff < 0 ? diff : 0) )) } } } internal class HeaderView: NSStackView { private var titleView: NSTextField? = nil private var activityButton: NSButton? private var settingsButton: NSButton? private var title: String = "" private var isCloseAction: Bool = false override init(frame: NSRect) { super.init(frame: CGRect(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)) self.orientation = .horizontal self.distribution = .gravityAreas self.spacing = 0 let activity = NSButtonWithPadding() activity.frame = CGRect(x: 0, y: 0, width: 24, height: self.frame.height) activity.horizontalPadding = activity.frame.height - 24 activity.bezelStyle = .regularSquare activity.translatesAutoresizingMaskIntoConstraints = false activity.imageScaling = .scaleNone activity.image = Bundle(for: type(of: self)).image(forResource: "chart")! activity.contentTintColor = .lightGray activity.isBordered = false activity.action = #selector(openActivityMonitor) activity.target = self activity.toolTip = localizedString("Open Activity Monitor") activity.focusRingType = .none self.activityButton = activity let title = NSTextField(frame: NSRect(x: 0, y: 0, width: frame.width/2, height: 18)) title.isEditable = false title.isSelectable = false title.isBezeled = false title.wantsLayer = true title.textColor = .textColor title.backgroundColor = .clear title.canDrawSubviewsIntoLayer = true title.alignment = .center title.font = NSFont.systemFont(ofSize: 16, weight: .regular) title.stringValue = "" self.titleView = title let settings = NSButtonWithPadding() settings.frame = CGRect(x: 0, y: 0, width: 24, height: self.frame.height) settings.horizontalPadding = activity.frame.height - 24 settings.bezelStyle = .regularSquare settings.translatesAutoresizingMaskIntoConstraints = false settings.imageScaling = .scaleNone settings.image = Bundle(for: type(of: self)).image(forResource: "settings")! settings.contentTintColor = .lightGray settings.isBordered = false settings.action = #selector(openSettings) settings.target = self settings.toolTip = localizedString("Open module settings") settings.focusRingType = .none self.settingsButton = settings self.addArrangedSubview(activity) self.addArrangedSubview(title) self.addArrangedSubview(settings) NSLayoutConstraint.activate([ title.widthAnchor.constraint( equalToConstant: self.frame.width - activity.intrinsicContentSize.width - settings.intrinsicContentSize.width ) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func setTitle(_ newTitle: String) { self.title = newTitle self.titleView?.stringValue = localizedString(newTitle) } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) NSColor.gridColor.set() let line = NSBezierPath() line.move(to: NSPoint(x: 0, y: 0)) line.line(to: NSPoint(x: self.frame.width, y: 0)) line.lineWidth = 1 line.stroke() } @objc func openActivityMonitor(_ sender: Any) { self.window?.setIsVisible(false) NSWorkspace.shared.launchApplication( withBundleIdentifier: "com.apple.ActivityMonitor", options: [.default], additionalEventParamDescriptor: nil, launchIdentifier: nil ) } @objc func openSettings(_ sender: Any) { self.window?.setIsVisible(false) NotificationCenter.default.post(name: .toggleSettings, object: nil, userInfo: ["module": self.title]) } @objc private func closePopup() { self.window?.setIsVisible(false) self.setCloseButton(false) return } public func setCloseButton(_ state: Bool) { if state && !self.isCloseAction { self.activityButton?.image = Bundle(for: type(of: self)).image(forResource: "close")! self.activityButton?.toolTip = localizedString("Close popup") self.activityButton?.action = #selector(self.closePopup) self.isCloseAction = true } else if !state && self.isCloseAction { self.activityButton?.image = Bundle(for: type(of: self)).image(forResource: "chart")! self.activityButton?.toolTip = localizedString("Open Activity Monitor") self.activityButton?.action = #selector(self.openActivityMonitor) self.isCloseAction = false } } }