601 lines
22 KiB
Swift
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()
|
|
}
|
|
}
|