Files
CmdBar/src/CmdViewController.swift
T
2025-03-22 19:50:22 -07:00

601 lines
22 KiB
Swift

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()
}
}