Files
macos-stats/Stats/Views/Settings.swift

506 lines
20 KiB
Swift

//
// Settings.swift
// Stats
//
// Created by Serhiy Mytrovtsiy on 12/04/2020.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2020 Serhiy Mytrovtsiy. All rights reserved.
//
import Cocoa
import Kit
public extension NSToolbarItem.Identifier {
static let toggleButton = NSToolbarItem.Identifier("toggleButton")
}
class SettingsWindow: NSWindow, NSWindowDelegate, NSToolbarDelegate {
private static let size: CGSize = CGSize(width: 720, height: 480)
private static let frameAutosaveName = "eu.exelban.Stats.Settings.WindowFrame"
private let mainView: MainView = MainView(frame: NSRect(x: 0, y: 0, width: 540, height: 480))
private let sidebarView: SidebarView = SidebarView(frame: NSRect(x: 0, y: 0, width: 180, height: 480))
private var dashboard: NSView = Dashboard()
private var settings: ApplicationSettings = ApplicationSettings()
private var toggleButton: NSControl? = nil
private var activeModuleName: String? = nil
private var pauseState: Bool { Store.shared.bool(key: "pause", defaultValue: false) }
init() {
super.init(
contentRect: NSRect(
x: NSScreen.main!.frame.width - SettingsWindow.size.width,
y: NSScreen.main!.frame.height - SettingsWindow.size.height,
width: SettingsWindow.size.width,
height: SettingsWindow.size.height
),
styleMask: [.closable, .titled, .miniaturizable, .fullSizeContentView, .resizable],
backing: .buffered,
defer: false
)
let sidebarViewController = NSSplitViewController()
let sidebarVC: NSViewController = NSViewController(nibName: nil, bundle: nil)
sidebarVC.view = self.sidebarView
let mainVC: NSViewController = NSViewController(nibName: nil, bundle: nil)
mainVC.view = self.mainView
let sidebarItem = NSSplitViewItem(sidebarWithViewController: sidebarVC)
let contentItem = NSSplitViewItem(viewController: mainVC)
sidebarItem.canCollapse = false
contentItem.canCollapse = false
sidebarViewController.addSplitViewItem(sidebarItem)
sidebarViewController.addSplitViewItem(contentItem)
contentItem.minimumThickness = 540
let newToolbar = NSToolbar(identifier: "eu.exelban.Stats.Settings.Toolbar")
newToolbar.allowsUserCustomization = false
newToolbar.autosavesConfiguration = true
newToolbar.displayMode = .default
newToolbar.showsBaselineSeparator = true
newToolbar.delegate = self
self.toolbar = newToolbar
self.contentViewController = sidebarViewController
self.titlebarAppearsTransparent = true
if #unavailable(macOS 26.0) {
self.backgroundColor = .clear
}
self.isRestorable = true
self.setFrameAutosaveName(SettingsWindow.frameAutosaveName)
if !self.setFrameUsingName(SettingsWindow.frameAutosaveName) {
self.positionCenter()
}
self.setIsVisible(false)
self.minSize = NSSize(width: SettingsWindow.size.width, height: SettingsWindow.size.height-Constants.Popup.headerHeight)
let windowController = NSWindowController()
windowController.window = self
windowController.loadWindow()
NotificationCenter.default.addObserver(self, selector: #selector(menuCallback), name: .openModuleSettings, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(toggleSettingsHandler), name: .toggleSettings, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(externalModuleToggle), name: .toggleModule, object: nil)
self.sidebarView.openMenu("Dashboard")
}
deinit {
NotificationCenter.default.removeObserver(self, name: .toggleSettings, object: nil)
NotificationCenter.default.removeObserver(self, name: .openModuleSettings, object: nil)
NotificationCenter.default.removeObserver(self, name: .toggleModule, object: nil)
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if event.type == NSEvent.EventType.keyDown && event.modifierFlags.contains(.command) {
if event.keyCode == 12 || event.keyCode == 13 {
self.setIsVisible(false)
return true
} else if event.keyCode == 46 {
self.miniaturize(event)
return true
}
}
return super.performKeyEquivalent(with: event)
}
override func mouseUp(with: NSEvent) {
NotificationCenter.default.post(name: .clickInSettings, object: nil, userInfo: nil)
}
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
switch itemIdentifier {
case .toggleButton:
let switchButton = NSSwitch()
switchButton.state = .on
switchButton.action = #selector(self.toggleEnable)
switchButton.target = self
switchButton.controlSize = .small
self.toggleButton = switchButton
let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier)
toolbarItem.toolTip = localizedString("Toggle the module")
toolbarItem.view = switchButton
toolbarItem.isBordered = false
return toolbarItem
default:
return nil
}
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [.flexibleSpace, .toggleButton]
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [.flexibleSpace, .toggleButton]
}
@objc private func toggleSettingsHandler(_ notification: Notification) {
if !self.isVisible {
self.setIsVisible(true)
self.makeKeyAndOrderFront(nil)
}
if !self.isKeyWindow {
self.orderFrontRegardless()
}
if var name = notification.userInfo?["module"] as? String {
if name == "Combined modules" { name = "Dashboard" }
self.sidebarView.openMenu(name)
}
}
@objc private func menuCallback(_ notification: Notification) {
if let title = notification.userInfo?["module"] as? String {
var view: NSView = NSView()
if let detectedModule = modules.first(where: { $0.config.name == title }) {
if let v = detectedModule.settings {
view = v
}
self.activeModuleName = detectedModule.config.name
toggleNSControlState(self.toggleButton, state: detectedModule.enabled ? .on : .off)
self.toggleButton?.isHidden = false
} else if title == "Dashboard" {
view = self.dashboard
self.toggleButton?.isHidden = true
} else if title == "Settings" {
self.settings.viewWillAppear()
view = self.settings
self.toggleButton?.isHidden = true
}
self.title = localizedString(title)
self.mainView.setView(view)
self.sidebarView.openMenu(title)
}
}
@objc private func toggleEnable(_ sender: NSControl) {
guard let moduleName = self.activeModuleName else { return }
NotificationCenter.default.post(name: .toggleModule, object: nil, userInfo: ["module": moduleName, "state": controlState(sender)])
}
@objc private func externalModuleToggle(_ notification: Notification) {
if let name = notification.userInfo?["module"] as? String, name == self.activeModuleName {
if let state = notification.userInfo?["state"] as? Bool {
toggleNSControlState(self.toggleButton, state: state ? .on : .off)
}
}
}
internal func setModules() {
self.sidebarView.setModules(modules)
if !self.pauseState && modules.filter({ $0.enabled != false && $0.available != false && !$0.menuBar.widgets.filter({ $0.isActive }).isEmpty }).isEmpty {
self.setIsVisible(true)
}
}
private func positionCenter() {
self.setFrameOrigin(NSPoint(
x: (NSScreen.main!.frame.width - SettingsWindow.size.width)/2,
y: ((NSScreen.main!.frame.height - SettingsWindow.size.height)/1.75)
))
}
}
// MARK: - MainView
private class MainView: NSView {
fileprivate let container: NSStackView = NSStackView()
override init(frame: NSRect) {
super.init(frame: NSRect.zero)
self.container.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.container)
NSLayoutConstraint.activate([
self.container.leadingAnchor.constraint(equalTo: leadingAnchor),
self.container.trailingAnchor.constraint(equalTo: trailingAnchor),
self.container.topAnchor.constraint(equalTo: topAnchor, constant: Constants.Popup.headerHeight*1.4),
self.container.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func setView(_ view: NSView) {
self.container.subviews.forEach{ $0.removeFromSuperview() }
self.container.addArrangedSubview(view)
NSLayoutConstraint.activate([
view.leftAnchor.constraint(equalTo: self.container.leftAnchor),
view.rightAnchor.constraint(equalTo: self.container.rightAnchor),
view.topAnchor.constraint(equalTo: self.container.topAnchor),
view.bottomAnchor.constraint(equalTo: self.container.bottomAnchor)
])
}
}
// MARK: - Sidebar
private class SidebarView: NSStackView {
private let scrollView: ScrollableStackView
private let supportPopover = NSPopover()
private var pauseButton: NSButton? = nil
private var pauseState: Bool {
get { Store.shared.bool(key: "pause", defaultValue: false) }
set { Store.shared.set(key: "pause", value: newValue) }
}
private var dashboardIcon: NSImage { NSImage(systemSymbolName: "circle.grid.3x3.fill", accessibilityDescription: nil)! }
private var settingsIcon: NSImage { iconFromSymbol(name: "gear", scale: .large) }
private var bugIcon: NSImage { iconFromSymbol(name: "ladybug", scale: .large) }
private var supportIcon: NSImage { iconFromSymbol(name: "heart.fill", scale: .large) }
private var pauseIcon: NSImage { iconFromSymbol(name: "pause.fill", scale: .large) }
private var resumeIcon: NSImage { iconFromSymbol(name: "play.fill", scale: .large) }
private var closeIcon: NSImage { iconFromSymbol(name: "power", scale: .large) }
override init(frame: NSRect) {
self.scrollView = ScrollableStackView(frame: NSRect(x: 0, y: 0, width: frame.width, height: frame.height))
self.scrollView.stackView.spacing = 0
self.scrollView.stackView.edgeInsets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
super.init(frame: frame)
self.orientation = .vertical
self.spacing = 0
self.widthAnchor.constraint(equalToConstant: frame.width).isActive = true
let spacer = NSView()
spacer.heightAnchor.constraint(equalToConstant: 10).isActive = true
self.scrollView.stackView.addArrangedSubview(MenuItem(icon: self.dashboardIcon, title: "Dashboard"))
self.scrollView.stackView.addArrangedSubview(spacer)
self.supportPopover.behavior = .transient
self.supportPopover.contentViewController = self.supportView()
let additionalButtons: NSStackView = NSStackView(frame: NSRect(x: 0, y: 0, width: frame.width, height: 45))
additionalButtons.heightAnchor.constraint(equalToConstant: 45).isActive = true
additionalButtons.orientation = .horizontal
additionalButtons.distribution = .fillEqually
additionalButtons.alignment = .centerY
additionalButtons.spacing = 0
let pauseButton = self.makeButton(title: localizedString("Pause the Stats"), image: self.pauseState ? self.resumeIcon : self.pauseIcon, action: #selector(togglePause))
self.pauseButton = pauseButton
additionalButtons.addArrangedSubview(self.makeButton(title: localizedString("Settings"), image: self.settingsIcon, action: #selector(openSettings)))
additionalButtons.addArrangedSubview(self.makeButton(title: localizedString("Support the application"), image: self.supportIcon, action: #selector(donate)))
additionalButtons.addArrangedSubview(self.makeButton(title: localizedString("Report a bug"), image: self.bugIcon, action: #selector(reportBug)))
additionalButtons.addArrangedSubview(pauseButton)
additionalButtons.addArrangedSubview(self.makeButton(title: localizedString("Close application"), image: self.closeIcon, action: #selector(closeApp)))
let emptySpace = NSView()
emptySpace.heightAnchor.constraint(equalToConstant: 28).isActive = true
self.addArrangedSubview(self.scrollView)
self.addArrangedSubview(additionalButtons)
NotificationCenter.default.addObserver(self, selector: #selector(listenForPause), name: .pause, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self, name: .pause, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func openMenu(_ title: String) {
self.scrollView.stackView.subviews.forEach({ (m: NSView) in
if let menu = m as? MenuItem {
if menu.title == title {
menu.activate()
} else {
menu.reset()
}
}
})
}
fileprivate func setModules(_ list: [Module]) {
list.reversed().forEach { (m: Module) in
if !m.available { return }
let menu: NSView = MenuItem(icon: m.config.icon, title: m.config.name)
self.scrollView.stackView.insertArrangedSubview(menu, at: 2)
}
}
private func makeButton(title: String, image: NSImage, action: Selector) -> NSButton {
let button = NSButton()
button.title = title
button.toolTip = title
button.bezelStyle = .regularSquare
button.translatesAutoresizingMaskIntoConstraints = false
button.imageScaling = .scaleNone
button.image = image
button.contentTintColor = .secondaryLabelColor
button.isBordered = false
button.action = action
button.target = self
button.focusRingType = .none
button.widthAnchor.constraint(equalToConstant: 33).isActive = true
let rect = NSRect(x: 0, y: 0, width: 33, height: 45)
let trackingArea = NSTrackingArea(
rect: rect,
options: [NSTrackingArea.Options.activeAlways, NSTrackingArea.Options.mouseEnteredAndExited, NSTrackingArea.Options.activeInActiveApp],
owner: self,
userInfo: ["button": title]
)
self.addTrackingArea(trackingArea)
return button
}
private func supportView() -> NSViewController {
let vc: NSViewController = NSViewController(nibName: nil, bundle: nil)
let view: NSStackView = NSStackView(frame: NSRect(x: 0, y: 0, width: 180, height: 54))
view.spacing = 10
view.edgeInsets = NSEdgeInsets(top: 0, left: 15, bottom: 0, right: 0)
view.orientation = .horizontal
let github = SupportButtonView(name: "GitHub Sponsors", image: "github", action: {
NSWorkspace.shared.open(URL(string: "https://github.com/sponsors/exelban")!)
})
let paypal = SupportButtonView(name: "PayPal", image: "paypal", action: {
NSWorkspace.shared.open(URL(string: "https://www.paypal.com/donate?hosted_button_id=3DS5JHDBATMTC")!)
})
let koFi = SupportButtonView(name: "Ko-fi", image: "ko-fi", action: {
NSWorkspace.shared.open(URL(string: "https://ko-fi.com/exelban")!)
})
let patreon = SupportButtonView(name: "Patreon", image: "patreon", action: {
NSWorkspace.shared.open(URL(string: "https://patreon.com/exelban")!)
})
view.addArrangedSubview(github)
view.addArrangedSubview(paypal)
view.addArrangedSubview(koFi)
view.addArrangedSubview(patreon)
vc.view = view
return vc
}
@objc private func openSettings() {
NotificationCenter.default.post(name: .openModuleSettings, object: nil, userInfo: ["module": "Settings"])
}
@objc private func reportBug() {
NSWorkspace.shared.open(URL(string: "https://github.com/exelban/stats/issues/new?template=bug_report.md")!)
}
@objc private func donate(_ sender: NSButton) {
self.supportPopover.show(relativeTo: sender.bounds, of: sender, preferredEdge: NSRectEdge.minY)
}
@objc private func closeApp(_ sender: NSButton) {
NSApp.terminate(sender)
}
@objc private func togglePause() {
self.pauseState = !self.pauseState
self.pauseButton?.toolTip = localizedString(self.pauseState ? "Resume the Stats" : "Pause the Stats")
self.pauseButton?.image = self.pauseState ? self.resumeIcon : self.pauseIcon
NotificationCenter.default.post(name: .pause, object: nil, userInfo: ["state": self.pauseState])
}
@objc func listenForPause() {
self.pauseButton?.toolTip = localizedString(self.pauseState ? "Resume the Stats" : "Pause the Stats")
self.pauseButton?.image = self.pauseState ? self.resumeIcon : self.pauseIcon
}
}
private class MenuItem: NSView {
fileprivate let title: String
private var active: Bool = false
private var imageView: NSImageView? = nil
private var titleView: NSTextField? = nil
init(icon: NSImage?, title: String) {
self.title = title
super.init(frame: NSRect.zero)
self.wantsLayer = true
self.layer?.cornerRadius = 5
var toolTip = ""
if title == "Settings" {
toolTip = localizedString("Open application settings")
} else if title == "Dashboard" {
toolTip = localizedString("Open dashboard")
} else {
toolTip = localizedString("Open \(title) settings")
}
self.toolTip = toolTip
let imageView = NSImageView()
if icon != nil {
imageView.image = icon!
}
imageView.frame = NSRect(x: 8, y: (32 - 18)/2, width: 18, height: 18)
imageView.wantsLayer = true
imageView.contentTintColor = .labelColor
self.imageView = imageView
let titleView = TextView(frame: NSRect(x: 34, y: ((32 - 16)/2) + 1, width: 100, height: 16))
titleView.textColor = .labelColor
titleView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
titleView.stringValue = localizedString(title)
self.titleView = titleView
self.addSubview(imageView)
self.addSubview(titleView)
NSLayoutConstraint.activate([
self.heightAnchor.constraint(equalToConstant: 32)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func mouseDown(with: NSEvent) {
self.activate()
}
fileprivate func activate() {
guard !self.active else { return }
self.active = true
NotificationCenter.default.post(name: .openModuleSettings, object: nil, userInfo: ["module": self.title])
self.layer?.backgroundColor = NSColor.selectedContentBackgroundColor.cgColor
self.imageView?.contentTintColor = .white
self.titleView?.textColor = .white
}
fileprivate func reset() {
self.layer?.backgroundColor = .clear
self.imageView?.contentTintColor = .labelColor
self.titleView?.textColor = .labelColor
self.active = false
}
}