// // 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() private let background: NSVisualEffectView = { let view = NSVisualEffectView(frame: NSRect.zero) view.blendingMode = .withinWindow view.material = .contentBackground view.state = .active view.translatesAutoresizingMaskIntoConstraints = false view.setContentHuggingPriority(.defaultLow, for: .horizontal) view.setContentHuggingPriority(.defaultLow, for: .vertical) view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) return view }() override init(frame: NSRect) { super.init(frame: NSRect.zero) self.translatesAutoresizingMaskIntoConstraints = false self.container.translatesAutoresizingMaskIntoConstraints = false self.addSubview(self.background, positioned: .below, relativeTo: .none) self.addSubview(self.container) NSLayoutConstraint.activate([ self.background.leadingAnchor.constraint(equalTo: leadingAnchor), self.background.trailingAnchor.constraint(equalTo: trailingAnchor), self.background.topAnchor.constraint(equalTo: topAnchor), self.background.bottomAnchor.constraint(equalTo: bottomAnchor), 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 } }