mirror of
https://github.com/TomHarte/CLK.git
synced 2024-08-16 16:28:59 +00:00
This allows the Macintosh and ST to fade out volume and settings even without having captured the mouse.
867 lines
27 KiB
Swift
867 lines
27 KiB
Swift
//
|
|
// MachineDocument.swift
|
|
// Clock Signal
|
|
//
|
|
// Created by Thomas Harte on 04/01/2016.
|
|
// Copyright 2016 Thomas Harte. All rights reserved.
|
|
//
|
|
|
|
import AudioToolbox
|
|
import Cocoa
|
|
import QuartzCore
|
|
|
|
class MachineDocument:
|
|
NSDocument,
|
|
NSWindowDelegate,
|
|
CSMachineDelegate,
|
|
CSScanTargetViewResponderDelegate,
|
|
CSAudioQueueDelegate,
|
|
CSROMReciverViewDelegate
|
|
{
|
|
// MARK: - Mutual Exclusion.
|
|
|
|
/// Ensures exclusive access between calls to self.machine.run and close().
|
|
private let actionLock = NSLock()
|
|
/// Ensures exclusive access between calls to machine.updateView and machine.drawView, and close().
|
|
private let drawLock = NSLock()
|
|
|
|
// MARK: - Machine details.
|
|
|
|
/// A description of the machine this document should represent once fully set up.
|
|
private var machineDescription: CSStaticAnalyser?
|
|
|
|
/// The active machine, following its successful creation.
|
|
private var machine: CSMachine!
|
|
|
|
/// @returns the appropriate window content aspect ratio for this @c self.machine.
|
|
private func aspectRatio() -> NSSize {
|
|
return NSSize(width: 4.0, height: 3.0)
|
|
}
|
|
|
|
/// The output audio queue, if any.
|
|
private var audioQueue: CSAudioQueue!
|
|
|
|
// MARK: - Main NIB connections.
|
|
|
|
/// The OpenGL view to receive this machine's display.
|
|
@IBOutlet weak var scanTargetView: CSScanTargetView!
|
|
|
|
/// The options view, if any.
|
|
@IBOutlet var optionsView: NSView!
|
|
@IBOutlet var optionsController: MachineController!
|
|
|
|
/// The activity panel, if one is deemed appropriate.
|
|
@IBOutlet var activityView: NSView!
|
|
|
|
/// The volume view.
|
|
@IBOutlet var volumeView: NSView!
|
|
@IBOutlet var volumeSlider: NSSlider!
|
|
|
|
// MARK: - NSDocument Overrides and NSWindowDelegate methods.
|
|
|
|
/// Links this class to the MachineDocument NIB.
|
|
override var windowNibName: NSNib.Name? {
|
|
return "MachineDocument"
|
|
}
|
|
|
|
convenience init(type typeName: String) throws {
|
|
self.init()
|
|
self.fileType = typeName
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
override func close() {
|
|
// Close any dangling sheets.
|
|
//
|
|
// Be warned: in 11.0 at least, if there are any panels then posting the endSheet request
|
|
// will defer the close(), and close() will be called again at the end of that animation.
|
|
//
|
|
// So: MAKE SURE IT'S SAFE TO ENTER THIS FUNCTION TWICE. Hence the non-assumption here about
|
|
// any windows still existing.
|
|
if let window = self.windowControllers.first?.window {
|
|
for sheet in window.sheets {
|
|
window.endSheet(sheet)
|
|
}
|
|
}
|
|
|
|
// Stop the machine, if any.
|
|
machine?.stop()
|
|
|
|
// End the update cycle.
|
|
actionLock.lock()
|
|
drawLock.lock()
|
|
machine = nil
|
|
scanTargetView.invalidate()
|
|
actionLock.unlock()
|
|
drawLock.unlock()
|
|
|
|
// Let the document controller do its thing.
|
|
super.close()
|
|
}
|
|
|
|
override func data(ofType typeName: String) throws -> Data {
|
|
throw NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: nil)
|
|
}
|
|
|
|
override func windowControllerDidLoadNib(_ aController: NSWindowController) {
|
|
super.windowControllerDidLoadNib(aController)
|
|
aController.window?.contentAspectRatio = self.aspectRatio()
|
|
volumeSlider.floatValue = pow(2.0, userDefaultsVolume())
|
|
|
|
volumeView.layer!.cornerRadius = 5.0
|
|
}
|
|
|
|
private var missingROMs: String = ""
|
|
func configureAs(_ analysis: CSStaticAnalyser) {
|
|
self.machineDescription = analysis
|
|
|
|
actionLock.lock()
|
|
drawLock.lock()
|
|
|
|
let missingROMs = NSMutableString()
|
|
if let machine = CSMachine(analyser: analysis, missingROMs: missingROMs) {
|
|
setRomRequesterIsVisible(false)
|
|
|
|
self.machine = machine
|
|
machine.setVolume(userDefaultsVolume())
|
|
setupMachineOutput()
|
|
} else {
|
|
self.missingROMs = missingROMs as String
|
|
requestRoms()
|
|
}
|
|
|
|
actionLock.unlock()
|
|
drawLock.unlock()
|
|
}
|
|
|
|
enum InteractionMode {
|
|
case notStarted, showingMachinePicker, showingROMRequester, showingMachine
|
|
}
|
|
private var interactionMode: InteractionMode = .notStarted
|
|
|
|
// 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 check whether
|
|
// the window is visible.
|
|
func windowDidUpdate(_ notification: Notification) {
|
|
if self.windowControllers.count > 0, let window = self.windowControllers[0].window, window.isVisible {
|
|
// Grab the regular window title, if it's not already stored.
|
|
if self.unadornedWindowTitle == "" {
|
|
self.unadornedWindowTitle = window.title
|
|
}
|
|
updateWindowTitle()
|
|
|
|
// If an interaction mode is not yet in effect, pick the proper one and display the relevant thing.
|
|
if self.interactionMode == .notStarted {
|
|
// If a full machine exists, just continue showing it.
|
|
if self.machine != nil {
|
|
self.interactionMode = .showingMachine
|
|
setupMachineOutput()
|
|
return
|
|
}
|
|
|
|
// If a machine has been picked but is not showing, there must be ROMs missing.
|
|
if self.machineDescription != nil {
|
|
self.interactionMode = .showingROMRequester
|
|
requestRoms()
|
|
return
|
|
}
|
|
|
|
// If a machine hasn't even been picked yet, show the machine picker.
|
|
self.interactionMode = .showingMachinePicker
|
|
Bundle.main.loadNibNamed("MachinePicker", owner: self, topLevelObjects: nil)
|
|
self.machinePicker?.establishStoredOptions()
|
|
window.beginSheet(self.machinePickerPanel!, completionHandler: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func windowDidEnterFullScreen(_ notification: Notification) {
|
|
updateActivityViewVisibility()
|
|
}
|
|
|
|
// MARK: - Connections Between Machine and the Outside World.
|
|
|
|
private func setupMachineOutput() {
|
|
if let machine = self.machine, let scanTargetView = self.scanTargetView, machine.view != scanTargetView {
|
|
// Establish the output aspect ratio and audio.
|
|
let aspectRatio = self.aspectRatio()
|
|
machine.setView(scanTargetView, aspectRatio: Float(aspectRatio.width / aspectRatio.height))
|
|
|
|
// Attach an options panel if one is available.
|
|
if let optionsNibName = self.machineDescription?.optionsNibName {
|
|
Bundle.main.loadNibNamed(optionsNibName, owner: self, topLevelObjects: nil)
|
|
if let optionsController = self.optionsController {
|
|
optionsController.machine = machine
|
|
optionsController.establishStoredOptions()
|
|
}
|
|
if let optionsView = self.optionsView, let superview = self.volumeView.superview {
|
|
// Apply rounded edges.
|
|
optionsView.layer!.cornerRadius = 5.0
|
|
|
|
// Add to the superview.
|
|
superview.addSubview(optionsView)
|
|
|
|
// Apply constraints to appear centred and above the volume view.
|
|
let constraints = [
|
|
optionsView.centerXAnchor.constraint(equalTo: volumeView.centerXAnchor),
|
|
optionsView.bottomAnchor.constraint(equalTo: volumeView.topAnchor, constant: -8.0),
|
|
]
|
|
superview.addConstraints(constraints)
|
|
}
|
|
}
|
|
|
|
// Set up a fader for the volume and options.
|
|
var fadingViews: [NSView] = []
|
|
if let optionsView = self.optionsView {
|
|
fadingViews.append(optionsView)
|
|
}
|
|
if let volumeView = self.volumeView {
|
|
fadingViews.append(volumeView)
|
|
}
|
|
optionsFader = ViewFader(views: fadingViews)
|
|
|
|
// Create and populate an activity display if required.
|
|
setupActivityDisplay()
|
|
|
|
machine.delegate = self
|
|
scanTargetView.responderDelegate = self
|
|
|
|
// If this machine has a mouse, enable mouse capture; also indicate whether usurption
|
|
// of the command key is desired.
|
|
scanTargetView.shouldCaptureMouse = machine.hasMouse
|
|
scanTargetView.shouldUsurpCommand = machine.shouldUsurpCommand
|
|
|
|
setupAudioQueueClockRate()
|
|
|
|
// Bring OpenGL view-holding window on top of the options panel and show the content.
|
|
scanTargetView.isHidden = false
|
|
scanTargetView.window!.makeKeyAndOrderFront(self)
|
|
scanTargetView.window!.makeFirstResponder(scanTargetView)
|
|
|
|
// Start forwarding best-effort updates.
|
|
machine.start()
|
|
}
|
|
}
|
|
|
|
func machineSpeakerDidChangeInputClock(_ machine: CSMachine) {
|
|
// setupAudioQueueClockRate not only needs blocking access to the machine,
|
|
// but may be triggered on an arbitrary thread by a running machine, and that
|
|
// running machine may not be able to stop running until it has been called
|
|
// (e.g. if it is currently trying to run_until an audio event). Break the
|
|
// deadlock with an async dispatch.
|
|
DispatchQueue.main.async {
|
|
self.setupAudioQueueClockRate()
|
|
}
|
|
}
|
|
|
|
private func setupAudioQueueClockRate() {
|
|
// Establish and provide the audio queue, taking advice as to an appropriate sampling rate.
|
|
//
|
|
// TODO: this needs to be threadsafe. FIX!
|
|
let maximumSamplingRate = CSAudioQueue.preferredSamplingRate()
|
|
let selectedSamplingRate = Float64(self.machine.idealSamplingRate(from: NSRange(location: 0, length: NSInteger(maximumSamplingRate))))
|
|
let isStereo = self.machine.isStereo
|
|
if selectedSamplingRate > 0 {
|
|
// [Re]create the audio queue only if necessary.
|
|
if self.audioQueue == nil || self.audioQueue.samplingRate != selectedSamplingRate || self.audioQueue != self.machine.audioQueue {
|
|
self.machine.audioQueue = nil
|
|
self.audioQueue = CSAudioQueue(samplingRate: Float64(selectedSamplingRate), isStereo:isStereo)
|
|
self.audioQueue.delegate = self
|
|
self.machine.audioQueue = self.audioQueue
|
|
self.machine.setAudioSamplingRate(Float(selectedSamplingRate), bufferSize:audioQueue.preferredBufferSize, stereo:isStereo)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Responds to the CSAudioQueueDelegate dry-queue warning message by requesting a machine update.
|
|
final func audioQueueIsRunningDry(_ audioQueue: CSAudioQueue) {
|
|
}
|
|
|
|
// MARK: - Pasteboard Forwarding.
|
|
|
|
/// Forwards any text currently on the pasteboard into the active machine.
|
|
func paste(_ sender: Any) {
|
|
let pasteboard = NSPasteboard.general
|
|
if let string = pasteboard.string(forType: .string), let machine = self.machine {
|
|
machine.paste(string)
|
|
}
|
|
}
|
|
|
|
// MARK: - Runtime Media Insertion.
|
|
|
|
/// Delegate message to receive drag and drop files.
|
|
final func scanTargetView(_ view: CSScanTargetView, didReceiveFileAt URL: URL) {
|
|
insertFile(URL)
|
|
}
|
|
|
|
/// Action for the insert menu command; displays an NSOpenPanel and then segues into the same process
|
|
/// as if a file had been received via drag and drop.
|
|
@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 {
|
|
self.insertFile(url)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func insertFile(_ URL: URL) {
|
|
// Try to insert media.
|
|
let mediaSet = CSMediaSet(fileAt: URL)
|
|
if !mediaSet.empty {
|
|
mediaSet.apply(to: self.machine)
|
|
return
|
|
}
|
|
|
|
// Failing that see whether a new machine is required.
|
|
// TODO.
|
|
if let newMachine = CSStaticAnalyser(fileAt: URL) {
|
|
machine?.stop()
|
|
self.interactionMode = .notStarted
|
|
self.scanTargetView.willChangeScanTargetOwner()
|
|
configureAs(newMachine)
|
|
}
|
|
}
|
|
|
|
// MARK: - Input Management.
|
|
|
|
/// Upon a resign key, immediately releases all ongoing input mechanisms — any currently pressed keys,
|
|
/// and joystick and mouse inputs.
|
|
func windowDidResignKey(_ notification: Notification) {
|
|
if let machine = self.machine {
|
|
machine.clearAllKeys()
|
|
machine.joystickManager = nil
|
|
}
|
|
self.scanTargetView.releaseMouse()
|
|
}
|
|
|
|
/// Upon becoming key, attaches joystick input to the machine.
|
|
func windowDidBecomeKey(_ notification: Notification) {
|
|
if let machine = self.machine {
|
|
machine.joystickManager = (DocumentController.shared as! DocumentController).joystickManager
|
|
}
|
|
}
|
|
|
|
/// Forwards key down events directly to the machine.
|
|
func keyDown(_ event: NSEvent) {
|
|
if let machine = self.machine {
|
|
machine.setKey(event.keyCode, characters: event.characters, isPressed: true)
|
|
}
|
|
}
|
|
|
|
/// Forwards key up events directly to the machine.
|
|
func keyUp(_ event: NSEvent) {
|
|
if let machine = self.machine {
|
|
machine.setKey(event.keyCode, characters: event.characters, isPressed: false)
|
|
}
|
|
}
|
|
|
|
/// Synthesies appropriate key up and key down events upon any change in modifiers.
|
|
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))
|
|
}
|
|
}
|
|
|
|
/// Forwards mouse movement events to the mouse.
|
|
func mouseMoved(_ event: NSEvent) {
|
|
if let machine = self.machine {
|
|
machine.addMouseMotionX(event.deltaX, y: event.deltaY)
|
|
}
|
|
}
|
|
|
|
/// Forwards mouse button down events to the mouse.
|
|
func mouseUp(_ event: NSEvent) {
|
|
if let machine = self.machine {
|
|
machine.setMouseButton(Int32(event.buttonNumber), isPressed: false)
|
|
}
|
|
}
|
|
|
|
/// Forwards mouse button up events to the mouse.
|
|
func mouseDown(_ event: NSEvent) {
|
|
if let machine = self.machine {
|
|
machine.setMouseButton(Int32(event.buttonNumber), isPressed: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - MachinePicker Outlets and Actions
|
|
|
|
@IBOutlet var machinePicker: MachinePicker?
|
|
@IBOutlet var machinePickerPanel: NSWindow?
|
|
@IBAction func createMachine(_ sender: NSButton?) {
|
|
let selectedMachine = machinePicker!.selectedMachine()
|
|
self.windowControllers[0].window?.endSheet(self.machinePickerPanel!)
|
|
self.machinePicker = nil
|
|
self.configureAs(selectedMachine)
|
|
}
|
|
|
|
@IBAction func tableViewDoubleClick(_ sender: NSTableView?) {
|
|
createMachine(nil)
|
|
}
|
|
|
|
@IBAction func cancelCreateMachine(_ sender: NSButton?) {
|
|
close()
|
|
}
|
|
|
|
// MARK: - ROMRequester Outlets and Actions
|
|
|
|
@IBOutlet var romRequesterPanel: NSWindow?
|
|
@IBOutlet var romRequesterText: NSTextField?
|
|
@IBOutlet var romReceiverErrorField: NSTextField?
|
|
@IBOutlet var romReceiverView: CSROMReceiverView?
|
|
private var romRequestBaseText = ""
|
|
|
|
private func setRomRequesterIsVisible(_ visible : Bool) {
|
|
if !visible && self.romRequesterPanel == nil {
|
|
return;
|
|
}
|
|
|
|
if self.romRequesterPanel!.isVisible == visible {
|
|
return
|
|
}
|
|
|
|
if visible {
|
|
self.windowControllers[0].window?.beginSheet(self.romRequesterPanel!, completionHandler: nil)
|
|
} else {
|
|
self.windowControllers[0].window?.endSheet(self.romRequesterPanel!)
|
|
}
|
|
}
|
|
|
|
func requestRoms() {
|
|
// Don't act yet if there's no window controller yet.
|
|
if self.windowControllers.count == 0 {
|
|
return
|
|
}
|
|
|
|
// Load the ROM requester dialogue if it's not already loaded.
|
|
if self.romRequesterPanel == nil {
|
|
Bundle.main.loadNibNamed("ROMRequester", owner: self, topLevelObjects: nil)
|
|
self.romReceiverView!.delegate = self
|
|
self.romRequestBaseText = romRequesterText!.stringValue
|
|
romReceiverErrorField?.alphaValue = 0.0
|
|
}
|
|
|
|
// Populate the current absentee list.
|
|
populateMissingRomList()
|
|
|
|
// Show the thing.
|
|
setRomRequesterIsVisible(true)
|
|
}
|
|
|
|
@IBAction func cancelRequestROMs(_ sender: NSButton?) {
|
|
close()
|
|
}
|
|
|
|
func populateMissingRomList() {
|
|
romRequesterText!.stringValue = self.romRequestBaseText + self.missingROMs
|
|
}
|
|
|
|
func romReceiverView(_ view: CSROMReceiverView, didReceiveFileAt URL: URL) {
|
|
// Test whether the file identified matches any of the currently missing ROMs.
|
|
// If so then remove that ROM from the missing list and update the request screen.
|
|
// If no ROMs are still missing, start the machine.
|
|
if CSMachine.attemptInstallROM(URL) {
|
|
configureAs(self.machineDescription!)
|
|
} else {
|
|
showRomReceiverError(error: "Didn't recognise contents of \(URL.lastPathComponent)")
|
|
}
|
|
}
|
|
|
|
// Yucky ugliness follows; my experience as an iOS developer intersects poorly with
|
|
// NSAnimationContext hence the various stateful diplications below. isShowingError
|
|
// should be essentially a duplicate of the current alphaValue, and animationCount
|
|
// is to resolve my inability to figure out how to cancel scheduled animations.
|
|
private var errorText = ""
|
|
private var isShowingError = false
|
|
private var animationCount = 0
|
|
private func showRomReceiverError(error: String) {
|
|
// Set or append the new error.
|
|
if self.errorText.count > 0 {
|
|
self.errorText = self.errorText + "\n" + error
|
|
} else {
|
|
self.errorText = error
|
|
}
|
|
|
|
// Apply the new complete text.
|
|
romReceiverErrorField!.stringValue = self.errorText
|
|
|
|
if !isShowingError {
|
|
// Schedule the box's appearance.
|
|
NSAnimationContext.beginGrouping()
|
|
NSAnimationContext.current.duration = 0.1
|
|
romReceiverErrorField?.animator().alphaValue = 1.0
|
|
NSAnimationContext.endGrouping()
|
|
isShowingError = true
|
|
}
|
|
|
|
// Schedule the box to disappear.
|
|
self.animationCount = self.animationCount + 1
|
|
let capturedAnimationCount = animationCount
|
|
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(2)) {
|
|
if self.animationCount == capturedAnimationCount {
|
|
NSAnimationContext.beginGrouping()
|
|
NSAnimationContext.current.duration = 1.0
|
|
self.romReceiverErrorField?.animator().alphaValue = 0.0
|
|
NSAnimationContext.endGrouping()
|
|
self.isShowingError = false
|
|
self.errorText = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Joystick-via-the-keyboard selection.
|
|
|
|
@IBAction func useKeyboardAsPhysicalKeyboard(_ sender: NSMenuItem?) {
|
|
machine.inputMode = .keyboardPhysical
|
|
}
|
|
|
|
@IBAction func useKeyboardAsLogicalKeyboard(_ sender: NSMenuItem?) {
|
|
machine.inputMode = .keyboardLogical
|
|
}
|
|
|
|
@IBAction func useKeyboardAsJoystick(_ sender: NSMenuItem?) {
|
|
machine.inputMode = .joystick
|
|
}
|
|
|
|
/// Determines which of the menu items to enable and disable based on the ability of the
|
|
/// current machine to handle keyboard and joystick input, accept new media and whether
|
|
/// it has an associted activity window.
|
|
override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
|
|
if let menuItem = item as? NSMenuItem {
|
|
switch item.action {
|
|
case #selector(self.useKeyboardAsPhysicalKeyboard):
|
|
if machine == nil || !machine.hasExclusiveKeyboard {
|
|
menuItem.state = .off
|
|
return false
|
|
}
|
|
|
|
menuItem.state = machine.inputMode == .keyboardPhysical ? .on : .off
|
|
return true
|
|
|
|
case #selector(self.useKeyboardAsLogicalKeyboard):
|
|
if machine == nil || !machine.hasExclusiveKeyboard {
|
|
menuItem.state = .off
|
|
return false
|
|
}
|
|
|
|
menuItem.state = machine.inputMode == .keyboardLogical ? .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.insertMedia(_:)):
|
|
return self.machine != nil && self.machine.canInsertMedia
|
|
|
|
default: break
|
|
}
|
|
}
|
|
return super.validateUserInterfaceItem(item)
|
|
}
|
|
|
|
// MARK: - Screenshots.
|
|
|
|
/// Saves a screenshot of the machine's current display.
|
|
@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.
|
|
let imageRepresentation = self.machine.imageRepresentation
|
|
|
|
// Encode as a PNG and save.
|
|
let pngData = imageRepresentation.representation(using: .png, properties: [:])
|
|
try! pngData?.write(to: url)
|
|
}
|
|
|
|
// MARK: - Window Title Updates.
|
|
|
|
private var unadornedWindowTitle = ""
|
|
private var mouseIsCaptured = false
|
|
private var windowTitleSuffix = ""
|
|
|
|
private func updateWindowTitle() {
|
|
var title = self.unadornedWindowTitle
|
|
if windowTitleSuffix != "" {
|
|
title += windowTitleSuffix
|
|
}
|
|
if mouseIsCaptured {
|
|
title += " (press ⌘+control to release mouse)"
|
|
}
|
|
self.windowControllers[0].window?.title = title
|
|
}
|
|
|
|
internal func scanTargetViewDidCaptureMouse(_ view: CSScanTargetView) {
|
|
mouseIsCaptured = true
|
|
updateWindowTitle()
|
|
}
|
|
|
|
internal func scanTargetViewDidReleaseMouse(_ view: CSScanTargetView) {
|
|
mouseIsCaptured = false
|
|
updateWindowTitle()
|
|
}
|
|
|
|
// MARK: - Activity Display.
|
|
|
|
private class LED {
|
|
let levelIndicator: NSLevelIndicator
|
|
init(levelIndicator: NSLevelIndicator, isPersistent: Bool) {
|
|
self.levelIndicator = levelIndicator
|
|
self.isPersistent = isPersistent
|
|
}
|
|
var isLit = false
|
|
var isBlinking = false
|
|
var isPersistent = false
|
|
}
|
|
private var leds: [String: LED] = [:]
|
|
private var activityFader: ViewFader! = nil
|
|
|
|
func setupActivityDisplay() {
|
|
var leds = machine.leds
|
|
if leds.count > 0 {
|
|
Bundle.main.loadNibNamed("Activity", owner: self, topLevelObjects: nil)
|
|
|
|
// Inspect the activity panel for indicators.
|
|
var activityIndicators: [NSLevelIndicator] = []
|
|
var textFields: [NSTextField] = []
|
|
if let activityView = self.activityView {
|
|
for view in activityView.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].name
|
|
self.leds[leds[c].name] = LED(levelIndicator: activityIndicators[c], isPersistent: leds[c].isPersisent)
|
|
}
|
|
|
|
// Create a fader.
|
|
activityFader = ViewFader(views: [self.activityView!])
|
|
|
|
// Add view to window, and constrain.
|
|
if let superview = activityIndicators[leds.count-1].superview {
|
|
superview.addConstraint(
|
|
activityIndicators[leds.count-1].bottomAnchor.constraint(equalTo: activityIndicators[leds.count-1].superview!.bottomAnchor, constant: -8.0)
|
|
)
|
|
}
|
|
if let windowView = self.volumeView.superview {
|
|
windowView.addSubview(self.activityView)
|
|
|
|
let constraints = [
|
|
self.activityView.rightAnchor.constraint(equalTo: windowView.rightAnchor),
|
|
self.activityView.topAnchor.constraint(equalTo: windowView.topAnchor),
|
|
]
|
|
windowView.addConstraints(constraints)
|
|
|
|
activityView.layer!.cornerRadius = 5.0
|
|
activityView.layer!.maskedCorners = [.layerMinXMinYCorner]
|
|
}
|
|
|
|
// Show or hide activity view as per current state.
|
|
updateActivityViewVisibility(true)
|
|
}
|
|
}
|
|
|
|
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.isLit {
|
|
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 { [self] in
|
|
// Do nothing for no change of state.
|
|
if led.isLit == isLit {
|
|
return
|
|
}
|
|
|
|
led.levelIndicator.floatValue = isLit ? 1.0 : 0.0
|
|
led.isLit = isLit
|
|
|
|
// Possibly show or hide the activity subview.
|
|
self.updateActivityViewVisibility(false, changed: ledName)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateActivityViewVisibility(_ isAppLaunch : Bool = false, changed: String? = nil) {
|
|
if let window = self.windowControllers.first?.window, let activityFader = self.activityFader {
|
|
// Rules applied below:
|
|
//
|
|
// Fullscreen:
|
|
// (i) always show activity view if any persistent LEDs are present;
|
|
// (ii) otherwise, show activity view only while at least one LED is lit.
|
|
//
|
|
// Windowed:
|
|
// (i) show while any non-persistent LED is lit;
|
|
// (ii) show transiently to indicate a change of state in any persistent LED.
|
|
//
|
|
let hasLitLEDs = !self.leds.filter {
|
|
$0.value.isLit && (!$0.value.isPersistent || window.styleMask.contains(.fullScreen)) ||
|
|
($0.value.isPersistent && window.styleMask.contains(.fullScreen))
|
|
}.isEmpty
|
|
let shouldShowTransient = !window.styleMask.contains(.fullScreen) && changed != nil && self.leds[changed!]!.isPersistent
|
|
|
|
if hasLitLEDs {
|
|
activityFader.animateIn()
|
|
} else if shouldShowTransient {
|
|
activityFader.showTransiently(for: 1.0)
|
|
} else {
|
|
activityFader.animateOut(delay: 0.2)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - In-window panels (i.e. options, volume).
|
|
|
|
private var optionsFader: ViewFader! = nil
|
|
|
|
internal func scanTargetViewDidShowOSMouseCursor(_ view: CSScanTargetView) {
|
|
// The OS mouse cursor became visible, so show the volume controls.
|
|
optionsFader.animateIn()
|
|
}
|
|
|
|
internal func scanTargetViewWouldHideOSMouseCursor(_ view: CSScanTargetView) {
|
|
// The OS mouse cursor will be hidden, so hide the volume controls.
|
|
optionsFader.animateOut(delay: 0.0)
|
|
}
|
|
|
|
// MARK: - Helpers for fading things in and out.
|
|
|
|
/// Maintains a list of views and offers in-and-out animations on those,
|
|
/// testing current state as necessary and otherwise coordinating with
|
|
/// CoreAnimation.
|
|
private class ViewFader: NSObject, CAAnimationDelegate {
|
|
private var views: [NSView]
|
|
|
|
init(views: [NSView]) {
|
|
self.views = views
|
|
for view in views {
|
|
view.isHidden = true
|
|
}
|
|
}
|
|
|
|
func animationDidStop(_ animation: CAAnimation, finished: Bool) {
|
|
if finished {
|
|
for view in views {
|
|
view.isHidden = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func animateIn() {
|
|
for view in views {
|
|
view.layer?.removeAllAnimations()
|
|
view.isHidden = false
|
|
}
|
|
}
|
|
|
|
func animateOut(delay : TimeInterval) {
|
|
// Do nothing if already animating out or invisible.
|
|
if views[0].isHidden || views[0].layer?.animation(forKey: "opacity") != nil {
|
|
return
|
|
}
|
|
|
|
for view in views {
|
|
let fadeAnimation = CABasicAnimation(keyPath: "opacity")
|
|
fadeAnimation.beginTime = CACurrentMediaTime() + delay
|
|
fadeAnimation.fromValue = 1.0
|
|
fadeAnimation.toValue = 0.0
|
|
fadeAnimation.duration = 0.2
|
|
fadeAnimation.delegate = self
|
|
|
|
fadeAnimation.fillMode = .forwards
|
|
fadeAnimation.isRemovedOnCompletion = false
|
|
|
|
view.layer?.removeAllAnimations()
|
|
view.layer!.add(fadeAnimation, forKey: "opacity")
|
|
}
|
|
}
|
|
|
|
func showTransiently(for period: TimeInterval) {
|
|
animateIn()
|
|
animateOut(delay: period)
|
|
}
|
|
}
|
|
|
|
// MARK: - Volume Control.
|
|
|
|
@IBAction func setVolume(_ sender: NSSlider!) {
|
|
if let machine = self.machine {
|
|
let linearValue = log2(sender.floatValue)
|
|
machine.setVolume(linearValue)
|
|
setUserDefaultsVolume(linearValue)
|
|
}
|
|
}
|
|
|
|
// The user's selected volume is stored as 1 - volume in the user defaults in order
|
|
// to take advantage of the default value being 0.
|
|
private func userDefaultsVolume() -> Float {
|
|
return 1.0 - UserDefaults.standard.float(forKey: "defaultVolume")
|
|
}
|
|
|
|
private func setUserDefaultsVolume(_ volume: Float) {
|
|
UserDefaults.standard.set(1.0 - volume, forKey: "defaultVolume")
|
|
}
|
|
}
|