// // MachineDocument.swift // Clock Signal // // Created by Thomas Harte on 04/01/2016. // Copyright 2016 Thomas Harte. All rights reserved. // import AudioToolbox import Cocoa 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() } } // MARK: Runtime media insertion. final func openGLView(_ view: CSOpenGLView, didReceiveFileAt URL: URL) { let mediaSet = CSMediaSet(fileAt: URL) if let mediaSet = mediaSet { mediaSet.apply(to: self.machine) } } @IBAction final func insertMedia(_ sender: AnyObject!) { let openPanel = NSOpenPanel() openPanel.message = "Hint: you can also insert media by dragging and dropping it onto the machine's window." openPanel.beginSheetModal(for: self.windowControllers[0].window!) { (response) in if response == .OK { for url in openPanel.urls { 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 self.windowControllers[0].window?.endSheet(self.machinePickerPanel!) } @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 { menuItem.state = .off return false } menuItem.state = machine.inputMode == .keyboard ? .on : .off return true case #selector(self.useKeyboardAsJoystick): if machine == nil || !machine.hasJoystick { menuItem.state = .off return false } menuItem.state = machine.inputMode == .joystick ? .on : .off return true case #selector(self.showActivity(_:)): return self.activityPanel != nil case #selector(self.insertMedia(_:)): return self.machine != nil && self.machine.canInsertMedia default: break } } return super.validateUserInterfaceItem(item) } // Screenshot capture. @IBAction func saveScreenshot(_ sender: AnyObject!) { // Grab a date formatter and form a file name. let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short dateFormatter.timeStyle = .long let filename = ("Clock Signal Screen Shot " + dateFormatter.string(from: Date()) + ".png").replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ":", with: ".") let pictursURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask)[0] let url = pictursURL.appendingPathComponent(filename) // Obtain the machine's current display. var imageRepresentation: NSBitmapImageRep? = nil self.openGLView.perform { imageRepresentation = self.machine.imageRepresentation } // Encode as a PNG and save. let pngData = imageRepresentation!.representation(using: .png, properties: [:]) try! pngData?.write(to: url) } // 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 } } } }