1
0
mirror of https://github.com/TomHarte/CLK.git synced 2024-07-17 13:29:02 +00:00
CLK/OSBindings/Mac/Clock Signal/Documents/MachineDocument.swift
Thomas Harte 1362906f94 Wires joystick support all the way through to machines.
Ensures there's only one joystick manager, which is shared by all machines, with input going only to the key window.
2018-07-22 16:55:47 -04:00

403 lines
12 KiB
Swift

//
// MachineDocument.swift
// Clock Signal
//
// Created by Thomas Harte on 04/01/2016.
// Copyright 2016 Thomas Harte. All rights reserved.
//
import Cocoa
import AudioToolbox
class MachineDocument:
NSDocument,
NSWindowDelegate,
CSMachineDelegate,
CSOpenGLViewDelegate,
CSOpenGLViewResponderDelegate,
CSBestEffortUpdaterDelegate,
CSAudioQueueDelegate
{
fileprivate let actionLock = NSLock()
fileprivate let drawLock = NSLock()
fileprivate let bestEffortLock = NSLock()
var machine: CSMachine!
var name: String! {
get {
return nil
}
}
var optionsPanelNibName: String?
func aspectRatio() -> NSSize {
return NSSize(width: 4.0, height: 3.0)
}
@IBOutlet weak var openGLView: CSOpenGLView!
@IBOutlet var optionsPanel: MachinePanel!
@IBAction func showOptions(_ sender: AnyObject!) {
optionsPanel?.setIsVisible(true)
}
@IBOutlet var activityPanel: NSPanel!
@IBAction func showActivity(_ sender: AnyObject!) {
activityPanel.setIsVisible(true)
}
fileprivate var audioQueue: CSAudioQueue! = nil
fileprivate var bestEffortUpdater: CSBestEffortUpdater?
override var windowNibName: NSNib.Name? {
return NSNib.Name(rawValue: "MachineDocument")
}
override func windowControllerDidLoadNib(_ aController: NSWindowController) {
super.windowControllerDidLoadNib(aController)
aController.window?.contentAspectRatio = self.aspectRatio()
setupMachineOutput()
}
// Attempting to show a sheet before the window is visible (such as when the NIB is loaded) results in
// a sheet mysteriously floating on its own. For now, use windowDidUpdate as a proxy to know that the window
// is visible, though it's a little premature.
func windowDidUpdate(_ notification: Notification) {
if self.shouldShowNewMachinePanel {
self.shouldShowNewMachinePanel = false
Bundle.main.loadNibNamed(NSNib.Name(rawValue: "MachinePicker"), owner: self, topLevelObjects: nil)
self.machinePicker?.establishStoredOptions()
self.windowControllers[0].window?.beginSheet(self.machinePickerPanel!, completionHandler: nil)
}
}
fileprivate func setupMachineOutput() {
if let machine = self.machine, let openGLView = self.openGLView {
// establish the output aspect ratio and audio
let aspectRatio = self.aspectRatio()
openGLView.perform(glContext: {
machine.setView(openGLView, aspectRatio: Float(aspectRatio.width / aspectRatio.height))
})
// attach an options panel if one is available
if let optionsPanelNibName = self.optionsPanelNibName {
Bundle.main.loadNibNamed(NSNib.Name(rawValue: optionsPanelNibName), owner: self, topLevelObjects: nil)
self.optionsPanel.machine = machine
self.optionsPanel?.establishStoredOptions()
showOptions(self)
}
machine.delegate = self
self.bestEffortUpdater = CSBestEffortUpdater()
// callbacks from the OpenGL may come on a different thread, immediately following the .delegate set;
// hence the full setup of the best-effort updater prior to setting self as a delegate
openGLView.delegate = self
openGLView.responderDelegate = self
setupAudioQueueClockRate()
// bring OpenGL view-holding window on top of the options panel and show the content
openGLView.isHidden = false
openGLView.window!.makeKeyAndOrderFront(self)
openGLView.window!.makeFirstResponder(openGLView)
// start accepting best effort updates
self.bestEffortUpdater!.delegate = self
}
}
func machineSpeakerDidChangeInputClock(_ machine: CSMachine) {
setupAudioQueueClockRate()
}
fileprivate func setupAudioQueueClockRate() {
// establish and provide the audio queue, taking advice as to an appropriate sampling rate
let maximumSamplingRate = CSAudioQueue.preferredSamplingRate()
let selectedSamplingRate = self.machine.idealSamplingRate(from: NSRange(location: 0, length: NSInteger(maximumSamplingRate)))
if selectedSamplingRate > 0 {
audioQueue = CSAudioQueue(samplingRate: Float64(selectedSamplingRate))
audioQueue.delegate = self
self.machine.audioQueue = self.audioQueue
self.machine.setAudioSamplingRate(selectedSamplingRate, bufferSize:audioQueue.preferredBufferSize)
}
}
override func close() {
activityPanel?.setIsVisible(false)
activityPanel = nil
optionsPanel?.setIsVisible(false)
optionsPanel = nil
bestEffortLock.lock()
if let bestEffortUpdater = bestEffortUpdater {
bestEffortUpdater.delegate = nil
bestEffortUpdater.flush()
self.bestEffortUpdater = nil
}
bestEffortLock.unlock()
actionLock.lock()
drawLock.lock()
machine = nil
openGLView.delegate = nil
openGLView.invalidate()
actionLock.unlock()
drawLock.unlock()
super.close()
}
// MARK: configuring
func configureAs(_ analysis: CSStaticAnalyser) {
if let machine = CSMachine(analyser: analysis) {
self.machine = machine
self.optionsPanelNibName = analysis.optionsPanelNibName
setupMachineOutput()
setupActivityDisplay()
}
}
fileprivate var shouldShowNewMachinePanel = false
override func read(from url: URL, ofType typeName: String) throws {
if let analyser = CSStaticAnalyser(fileAt: url) {
self.displayName = analyser.displayName
self.configureAs(analyser)
} else {
throw NSError(domain: "MachineDocument", code: -1, userInfo: nil)
}
}
convenience init(type typeName: String) throws {
self.init()
self.fileType = typeName
self.shouldShowNewMachinePanel = true
}
// MARK: the pasteboard
func paste(_ sender: Any) {
let pasteboard = NSPasteboard.general
if let string = pasteboard.string(forType: .string) {
self.machine.paste(string)
}
}
// MARK: CSBestEffortUpdaterDelegate
final func bestEffortUpdater(_ bestEffortUpdater: CSBestEffortUpdater!, runForInterval duration: TimeInterval, didSkipPreviousUpdate: Bool) {
if actionLock.try() {
self.machine.run(forInterval: duration)
actionLock.unlock()
}
}
// MARK: CSAudioQueueDelegate
final func audioQueueIsRunningDry(_ audioQueue: CSAudioQueue) {
bestEffortLock.lock()
bestEffortUpdater?.update()
bestEffortLock.unlock()
}
// MARK: CSOpenGLViewDelegate
final func openGLView(_ view: CSOpenGLView, drawViewOnlyIfDirty onlyIfDirty: Bool) {
bestEffortLock.lock()
if let bestEffortUpdater = bestEffortUpdater {
bestEffortLock.unlock()
bestEffortUpdater.update()
if drawLock.try() {
self.machine.drawView(forPixelSize: view.backingSize, onlyIfDirty: onlyIfDirty)
drawLock.unlock()
}
} else {
bestEffortLock.unlock()
}
}
final func openGLView(_ view: CSOpenGLView, didReceiveFileAt URL: URL) {
let mediaSet = CSMediaSet(fileAt: URL)
if let mediaSet = mediaSet {
mediaSet.apply(to: self.machine)
}
}
// MARK: NSDocument overrides
override func data(ofType typeName: String) throws -> Data {
throw NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: nil)
}
// MARK: Input management
func windowDidResignKey(_ notification: Notification) {
if let machine = self.machine {
machine.clearAllKeys()
machine.joystickManager = nil
}
}
func windowDidBecomeKey(_ notification: Notification) {
if let machine = self.machine {
machine.joystickManager = (DocumentController.shared as! DocumentController).joystickManager
}
}
func keyDown(_ event: NSEvent) {
if let machine = self.machine {
machine.setKey(event.keyCode, characters: event.characters, isPressed: true)
}
}
func keyUp(_ event: NSEvent) {
if let machine = self.machine {
machine.setKey(event.keyCode, characters: event.characters, isPressed: false)
}
}
func flagsChanged(_ newModifiers: NSEvent) {
if let machine = self.machine {
machine.setKey(VK_Shift, characters: nil, isPressed: newModifiers.modifierFlags.contains(.shift))
machine.setKey(VK_Control, characters: nil, isPressed: newModifiers.modifierFlags.contains(.control))
machine.setKey(VK_Command, characters: nil, isPressed: newModifiers.modifierFlags.contains(.command))
machine.setKey(VK_Option, characters: nil, isPressed: newModifiers.modifierFlags.contains(.option))
}
}
// MARK: New machine creation
@IBOutlet var machinePicker: MachinePicker?
@IBOutlet var machinePickerPanel: NSWindow?
@IBAction func createMachine(_ sender: NSButton?) {
self.configureAs(machinePicker!.selectedMachine())
machinePicker = nil
sender?.window?.close()
}
@IBAction func cancelCreateMachine(_ sender: NSButton?) {
close()
}
// MARK: Joystick-via-the-keyboard selection
@IBAction func useKeyboardAsKeyboard(_ sender: NSMenuItem?) {
machine.inputMode = .keyboard
}
@IBAction func useKeyboardAsJoystick(_ sender: NSMenuItem?) {
machine.inputMode = .joystick
}
override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
if let menuItem = item as? NSMenuItem {
switch item.action {
case #selector(self.useKeyboardAsKeyboard):
if machine == nil || !machine.hasKeyboard {
return false
}
menuItem.state = machine.inputMode == .keyboard ? .on : .off
return true
case #selector(self.useKeyboardAsJoystick):
if machine == nil || !machine.hasJoystick {
return false
}
menuItem.state = machine.inputMode == .joystick ? .on : .off
return true
case #selector(self.showActivity(_:)):
return self.activityPanel != nil
default: break
}
}
return super.validateUserInterfaceItem(item)
}
// MARK: Activity display.
class LED {
let levelIndicator: NSLevelIndicator
init(levelIndicator: NSLevelIndicator) {
self.levelIndicator = levelIndicator
}
var isLit = false
var isBlinking = false
}
fileprivate var leds: [String: LED] = [:]
func setupActivityDisplay() {
var leds = machine.leds
if leds.count > 0 {
Bundle.main.loadNibNamed(NSNib.Name(rawValue: "Activity"), owner: self, topLevelObjects: nil)
showActivity(nil)
// Inspect the activity panel for indicators.
var activityIndicators: [NSLevelIndicator] = []
var textFields: [NSTextField] = []
if let contentView = self.activityPanel.contentView {
for view in contentView.subviews {
if let levelIndicator = view as? NSLevelIndicator {
activityIndicators.append(levelIndicator)
}
if let textField = view as? NSTextField {
textFields.append(textField)
}
}
}
// If there are fewer level indicators than LEDs, trim that list.
if activityIndicators.count < leds.count {
leds.removeSubrange(activityIndicators.count ..< leds.count)
}
// Remove unused views.
for c in leds.count ..< activityIndicators.count {
textFields[c].removeFromSuperview()
activityIndicators[c].removeFromSuperview()
}
// Apply labels and create leds entries.
for c in 0 ..< leds.count {
textFields[c].stringValue = leds[c]
self.leds[leds[c]] = LED(levelIndicator: activityIndicators[c])
}
// Add a constraints to minimise window height.
let heightConstraint = NSLayoutConstraint(
item: self.activityPanel.contentView!,
attribute: .bottom,
relatedBy: .equal,
toItem: activityIndicators[leds.count-1],
attribute: .bottom,
multiplier: 1.0,
constant: 20.0)
self.activityPanel.contentView?.addConstraint(heightConstraint)
}
}
func machine(_ machine: CSMachine, ledShouldBlink ledName: String) {
// If there is such an LED, switch it off for 0.03 of a second; if it's meant
// to be off at the end of that, leave it off. Don't allow the blinks to
// pile up allow there to be only one in flight at a time.
if let led = leds[ledName] {
DispatchQueue.main.async {
if !led.isBlinking {
led.levelIndicator.floatValue = 0.0
led.isBlinking = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
led.levelIndicator.floatValue = led.isLit ? 1.0 : 0.0
led.isBlinking = false
}
}
}
}
}
func machine(_ machine: CSMachine, led ledName: String, didChangeToLit isLit: Bool) {
// If there is such an LED, switch it appropriately.
if let led = leds[ledName] {
DispatchQueue.main.async {
led.levelIndicator.floatValue = isLit ? 1.0 : 0.0
led.isLit = isLit
}
}
}
}