import AppKit import Carbon import ServiceManagement fileprivate enum Metrics { static let contentMinWidth = 400.0 static let contentMaxWidth = /*600.0*/ NSScreen.main!.visibleFrame.size.width * 0.7 // WARNING: Under what circumstances can NSScreen return nil? static let contentMinHeight = 160.0 static let contentMaxHeight = /*800.0*/ NSScreen.main!.visibleFrame.size.height * 0.8 // WARNING: Under what circumstances can NSScreen return nil? static let padding = 10.0 static let buttonSpacing = 2.0 } class CmdViewController: NSViewController { private weak var cmdFile: CmdFile? private var timer: DispatchSourceTimer? private var keyboardEvents: EventMonitor? private var maxWidthContraint: NSLayoutConstraint! private var maxHeightContraint: NSLayoutConstraint! private var visualEffectView: NSView = { let visualEffect = NSVisualEffectView() visualEffect.blendingMode = .behindWindow visualEffect.material = .popover visualEffect.state = .active visualEffect.translatesAutoresizingMaskIntoConstraints = false return visualEffect }() private var textLabelScrollView: NSScrollView = { let scrollView = NSScrollView() scrollView.automaticallyAdjustsContentInsets = false scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true scrollView.scrollerStyle = .overlay scrollView.drawsBackground = false scrollView.translatesAutoresizingMaskIntoConstraints = false return scrollView }() private var textLabel: NSTextField = { let textField = NSTextField() // NOTE: Setting drawsBackground = false messes with geometry. // Also, setting a custom cell, messes up, too. textField.backgroundColor = .clear textField.isSelectable = true textField.isHidden = false textField.isEditable = false textField.isBezeled = false textField.alignment = .left textField.textColor = NSColor(name: nil) { getColors(.black, .white, for: $0) } textField.font = NSFont .monospacedSystemFont(ofSize: NSFontDescriptor .preferredFontDescriptor(forTextStyle: .body).pointSize, weight: .regular) textField.translatesAutoresizingMaskIntoConstraints = false return textField }() private var reloadsText: NSTextField = { let textField = NSTextField() textField.isHidden = false textField.stringValue = "--" textField.lineBreakMode = .byTruncatingTail textField.maximumNumberOfLines = 1 textField.isEditable = false textField.isBezeled = false textField.drawsBackground = false textField.alignment = .left textField.textColor = NSColor(name: nil) { getColors(.darkGray, .lightGray, for: $0) } textField.font = NSFont .systemFont(ofSize: NSFontDescriptor .preferredFontDescriptor(forTextStyle: .subheadline).pointSize, weight: .bold) textField.translatesAutoresizingMaskIntoConstraints = false return textField }() private var progressView: CircularProgressView = { let progress = CircularProgressView() progress.translatesAutoresizingMaskIntoConstraints = false return progress }() private var statusText: NSTextField = { let textField = NSTextField() textField.isHidden = false textField.stringValue = "Loading..." textField.isEditable = false textField.isBezeled = false textField.drawsBackground = false textField.alignment = .center textField.textColor = NSColor(name: nil) { getColors(.darkGray, .lightGray, for: $0) } textField.font = NSFont(descriptor: NSFontDescriptor .preferredFontDescriptor(forTextStyle: .body), size: 0) textField.translatesAutoresizingMaskIntoConstraints = false return textField }() private var quitButton: NSButton = { let button = NSButton() button.image = systemImage("xmark.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemRed])) button.isBordered = false button.action = #selector(terminateApp) button.sizeToFit() button.toolTip = "Quit" button.translatesAutoresizingMaskIntoConstraints = false return button }() private var reloadButton: NSButton = { let button = NSButton() button.image = systemImage("arrow.clockwise.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemBlue])) button.isBordered = false button.action = #selector(reloadWidget) button.sizeToFit() button.toolTip = "Reload Current" button.translatesAutoresizingMaskIntoConstraints = false return button }() private var startButton: NSButton = { let button = NSButton() button.image = systemImage("sun.max.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemOrange])) button.isBordered = false button.action = #selector(openAtLogin) button.sizeToFit() button.toolTip = "Open at Login — OFF" button.translatesAutoresizingMaskIntoConstraints = false return button }() private var aboutButton: NSButton = { let button = NSButton() button.image = systemImage("info.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemGray])) button.isBordered = false button.action = #selector(showAbout) button.sizeToFit() button.toolTip = "About" button.translatesAutoresizingMaskIntoConstraints = false return button }() // private var updateButton: NSButton = { // let button = NSButton() // // TODO: WHY??!!! // button.image = NSImage(systemSymbolName: "arrow.down.circle.fill", // accessibilityDescription: nil)? // .withSymbolConfiguration(NSImage // .SymbolConfiguration(textStyle: .title2, scale: .large) // .applying(.init(paletteColors: [.white, .systemGreen]))) // button.isBordered = false // button.action = #selector(updateApp) // button.sizeToFit() // button.toolTip = "About" // button.toolTip = "Update" // button.translatesAutoresizingMaskIntoConstraints = false // return button // }() private var buttonsBackground: NSView = { let blurView = NSVisualEffectView() blurView.blendingMode = .behindWindow blurView.material = .sheet blurView.state = .active blurView.wantsLayer = true blurView.layer?.borderColor = NSColor.systemGray.withAlphaComponent(0.05).cgColor blurView.layer?.borderWidth = 1.5 blurView.translatesAutoresizingMaskIntoConstraints = false return blurView }() private var buttonsContainer: NSLayoutGuide = { let container = NSLayoutGuide() return container }() private func addSubviews() { view.addSubview(visualEffectView) view.addSubview(statusText) textLabelScrollView.documentView = textLabel view.addSubview(textLabelScrollView) view.addLayoutGuide(buttonsContainer) view.addSubview(buttonsBackground) view.addSubview(quitButton) view.addSubview(reloadButton) view.addSubview(startButton) view.addSubview(aboutButton) view.addSubview(reloadsText) // view.addSubview(updateButton) view.addSubview(progressView) } override func viewDidLoad() { super.viewDidLoad() addSubviews() setup() setContraints() } override func loadView() { self.view = QuietView() } override func viewWillAppear() { maxWidthContraint.constant = NSScreen.main!.visibleFrame.size.width * 0.7 maxHeightContraint.constant = NSScreen.main!.visibleFrame.size.height * 0.8 } override func viewDidAppear() { super.viewDidAppear() // TODO: Check so that there are weird behaviors on the textLabelScrollView.flashScrollers() startReloadTextTimer() keyboardEvents?.start() resetReloadButton() } override func viewDidDisappear() { super.viewDidDisappear() timer?.cancel() timer = nil keyboardEvents?.stop() if let editor = textLabel.currentEditor() { textLabel.endEditing(editor) } } private func setContraints() { setViewConstraints() setTextConstraints() setReloadConstrains() setButtonConstraints() } func setup() { openAtLoginToggle() buttonsBackground.layer?.cornerRadius = quitButton.bounds.height / Metrics.buttonSpacing + Metrics.buttonSpacing setupKeyEvents() } private func setReloadConstrains() { NSLayoutConstraint.activate([ progressView.widthAnchor.constraint(equalToConstant: 16), progressView.heightAnchor.constraint(equalToConstant: 16), progressView.leadingAnchor .constraint(equalTo: view.leadingAnchor, constant: Metrics.padding), progressView.centerYAnchor .constraint(equalTo: reloadsText.centerYAnchor), ]) NSLayoutConstraint.activate([ reloadsText.leadingAnchor .constraint(equalTo: progressView.trailingAnchor, constant: Metrics.buttonSpacing), reloadsText.centerYAnchor .constraint(equalTo: buttonsBackground.centerYAnchor), reloadsText.trailingAnchor .constraint(equalTo: buttonsBackground.leadingAnchor, constant: -Metrics.buttonSpacing), ]) } private func setViewConstraints() { NSLayoutConstraint.activate([ visualEffectView.topAnchor.constraint(equalTo: view.topAnchor), visualEffectView.bottomAnchor .constraint(equalTo: view.bottomAnchor), visualEffectView.leadingAnchor .constraint(equalTo: view.leadingAnchor), visualEffectView.trailingAnchor .constraint(equalTo: view.trailingAnchor), ]) } private func setButtonConstraints() { NSLayoutConstraint.activate([ quitButton.widthAnchor .constraint(equalToConstant: quitButton.bounds.width), quitButton.heightAnchor .constraint(equalToConstant: quitButton.bounds.height), reloadButton.widthAnchor .constraint(equalToConstant: reloadButton.bounds.width), reloadButton.heightAnchor .constraint(equalToConstant: reloadButton.bounds.height), startButton.widthAnchor .constraint(equalToConstant: startButton.bounds.width), startButton.heightAnchor .constraint(equalToConstant: startButton.bounds.height), aboutButton.widthAnchor .constraint(equalToConstant: aboutButton.bounds.width), aboutButton.heightAnchor .constraint(equalToConstant: aboutButton.bounds.height), // updateButton.widthAnchor // .constraint(equalToConstant: updateButton.bounds.width), // updateButton.heightAnchor // .constraint(equalToConstant: updateButton.bounds.height), buttonsContainer.trailingAnchor .constraint(equalTo: view.trailingAnchor, constant: -Metrics.padding), buttonsContainer.bottomAnchor .constraint(equalTo: view.bottomAnchor, constant: -Metrics.padding), buttonsBackground.leadingAnchor .constraint(equalTo: buttonsContainer.leadingAnchor), buttonsBackground.trailingAnchor .constraint(equalTo: buttonsContainer.trailingAnchor), buttonsBackground.topAnchor .constraint(equalTo: buttonsContainer.topAnchor), buttonsBackground.bottomAnchor .constraint(equalTo: buttonsContainer.bottomAnchor), quitButton.trailingAnchor .constraint(equalTo: buttonsContainer.trailingAnchor, constant: -Metrics.buttonSpacing), quitButton.topAnchor .constraint(equalTo: buttonsContainer.topAnchor, constant: Metrics.buttonSpacing), quitButton.bottomAnchor .constraint(equalTo: buttonsContainer.bottomAnchor, constant: -Metrics.buttonSpacing), reloadButton.firstBaselineAnchor .constraint(equalTo: quitButton.firstBaselineAnchor), reloadButton.trailingAnchor .constraint(equalTo: quitButton.leadingAnchor, constant: -Metrics.buttonSpacing), startButton.firstBaselineAnchor .constraint(equalTo: reloadButton.firstBaselineAnchor), startButton.trailingAnchor .constraint(equalTo: reloadButton.leadingAnchor, constant: -Metrics.buttonSpacing), aboutButton.firstBaselineAnchor .constraint(equalTo: startButton.firstBaselineAnchor), aboutButton.trailingAnchor .constraint(equalTo: startButton.leadingAnchor, constant: -Metrics.buttonSpacing), aboutButton.leadingAnchor .constraint(equalTo: buttonsContainer.leadingAnchor, constant: Metrics.buttonSpacing), // updateButton.firstBaselineAnchor // .constraint(equalTo: startButton.firstBaselineAnchor), // updateButton.leadingAnchor // .constraint(equalTo: buttonsContainer.leadingAnchor, // constant: Metrics.buttonSpacing), // updateButton.trailingAnchor // .constraint(equalTo: aboutButton.leadingAnchor, // constant: -Metrics.buttonSpacing), ]) } private func setTextConstraints() { NSLayoutConstraint.activate([ statusText.centerXAnchor .constraint(equalTo: view.centerXAnchor), statusText .centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) let trailing = textLabel.trailingAnchor .constraint(equalTo: textLabelScrollView.trailingAnchor) trailing.priority = .init(600) let bottom = textLabel.bottomAnchor .constraint(equalTo: textLabelScrollView.bottomAnchor) bottom.priority = .init(600) maxWidthContraint = textLabelScrollView.widthAnchor .constraint(lessThanOrEqualToConstant: Metrics.contentMaxWidth) maxHeightContraint = textLabelScrollView.heightAnchor .constraint(lessThanOrEqualToConstant: Metrics.contentMaxHeight) textLabel.setContentHuggingPriority(.required, for: .horizontal) NSLayoutConstraint.activate([ textLabel.widthAnchor .constraint( greaterThanOrEqualToConstant: Metrics.contentMinWidth ), // textLabelScrollView.widthAnchor // .constraint( // lessThanOrEqualToConstant: Metrics.contentMaxWidth // ), maxWidthContraint, textLabel.heightAnchor .constraint( greaterThanOrEqualToConstant: Metrics.contentMinHeight ), // textLabelScrollView.heightAnchor // .constraint( // lessThanOrEqualToConstant: Metrics.contentMaxHeight // ), maxHeightContraint, textLabel.topAnchor .constraint(equalTo: textLabelScrollView.topAnchor), bottom, textLabel.leadingAnchor .constraint(equalTo: textLabelScrollView.leadingAnchor), trailing, textLabelScrollView.topAnchor .constraint(equalTo: view.topAnchor, constant: 10), textLabelScrollView.bottomAnchor .constraint(equalTo: buttonsContainer.topAnchor, constant: -10), textLabelScrollView.leadingAnchor .constraint(equalTo: view.leadingAnchor, constant: 10), textLabelScrollView.trailingAnchor .constraint(equalTo: view.trailingAnchor, constant: -10), ]) } private func resetReloadButton() { reloadButton.image = systemImage("arrow.clockwise.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemBlue])) reloadButton.action = #selector(reloadWidget) reloadButton.toolTip = "Reload Current" } private func startReloadTextTimer() { timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global()) timer?.schedule(deadline: .now(), repeating: .milliseconds(500)) timer?.setEventHandler { [weak self] in DispatchQueue.main.async { guard let controller = self, let file = controller.cmdFile else { return } controller.setUntil("\(stringifySeconds(file.untilNextExec))") controller.progressView.value = Double(file.untilNextExec) } } timer?.resume() } private func setupKeyEvents() { keyboardEvents = LocalEventMonitor(mask: [.flagsChanged, .keyDown], handler: { [weak self] event in let modifiers = event.modifierFlags.rawValue let key = event.keyCode if modsContains(keys: OSShift, in: modifiers) { self?.reloadButton.image = systemImage("arrow.clockwise.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemCyan])) self?.reloadButton.action = #selector(self?.reloadWidgets) self?.reloadButton.toolTip = "Reload All" } else { self?.reloadButton.image = systemImage("arrow.clockwise.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemBlue])) self?.reloadButton.action = #selector(self?.reloadWidget) self?.reloadButton.toolTip = "Reload Current" } if modsContains(keys: OSCmd, in: modifiers) { if key == kVK_ANSI_Q { self?.terminateApp() } else if key == kVK_ANSI_W { self?.view.superview?.window?.resignKey() } else if key == kVK_ANSI_R { self?.reloadWidget() } else if key == kVK_ANSI_C { self?.textLabel.currentEditor()?.copy(nil) } } else if modsContains(keys: OSCmd | OSShift, in: modifiers) { if key == kVK_ANSI_R { // NOTE: We need to resign the window in order to prevent the program from intercepting the events, which result in sounds // beign made for missing performKeyEquivalent. self?.view.window?.resignKey() self?.reloadWidgets() } } else if modsContainsNone(in: modifiers) { if key == kVK_ANSI_Q || key == kVK_Escape { self?.view.superview?.window?.resignKey() } } return event }) } func setFile(_ file: CmdFile) { self.cmdFile = file progressView.minValue = 0.0 progressView.maxValue = Double(file.reloadTime) progressView.value = Double(file.reloadTime) } func setText(_ text: String) { if text.isEmpty { statusText.stringValue = "Empty" textLabel.stringValue = "" statusText.isHidden = false textLabel.isHidden = true } else { textLabel.stringValue = text statusText.isHidden = true textLabel.isHidden = false } } func setUntil(_ text: String) { reloadsText.stringValue = text } @objc private func reloadWidgets() { delegate.reloadWidgets() } @objc private func reloadWidget() { cmdFile?.restartTimer() statusText.stringValue = "Loading..." textLabel.isHidden = true statusText.isHidden = false } @objc private func showAbout() { delegate.showAbout() } @objc private func openAtLogin() { delegate.toggleLaunchAtLogin() openAtLoginToggle() } @objc func terminateApp() { delegate.terminateApp() } @objc func updateApp() { delegate.checkForUpdates() } private func openAtLoginToggle() { if SMAppService.mainApp.status == .enabled { startButton.toolTip = "Open at Login — ON" startButton.image = systemImage("sunrise.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemOrange])) } else { startButton.toolTip = "Open at Login — OFF" startButton.image = systemImage("sun.max.circle.fill", .title2, .large, .init(paletteColors: [.white, .systemOrange])) } } deinit { if timer != nil { timer!.cancel() timer = nil } keyboardEvents?.stop() } }