/* * main.swift: Cocoa frontend for xC * * This is in effect a port of xW.cpp to macOS frameworks. * * Copyright (c) 2023, Přemysl Eric Janouch * * Permission to use, copy, modify, and/or distribute this software for any * purpose with or without fee is hereby granted. * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. * */ import AppKit import Network import os let projectName = "xM" // --- RPC --------------------------------------------------------------------- class RelayRPC { var connection: NWConnection? // Callbacks: var onConnected: (() -> Void)? var onFailed: ((String) -> Void)? var onEvent: ((RelayEventMessage) -> Void)? // Command processing: typealias Callback = (String, RelayResponseData?) -> () /// Outgoing message counter var commandSeq: UInt32 = 0 /// Callbacks for messages processed by the server var commandCallbacks = Dictionary() func resetState() { self.connection = nil // TODO(p): Consider cancelling all of them from here. self.commandCallbacks.removeAll() } func connect(host: String, port: String) -> Bool { let nwHost = NWEndpoint.Host(host) let nwPort = NWEndpoint.Port(port) if nwPort == nil { return false } // TODO(p): Consider how to behave after failure. self.resetState() self.connection = NWConnection(host: nwHost, port: nwPort!, using: .tcp) self.connection!.stateUpdateHandler = onStateChange(to:) self.receiveFrame() // We directly update the UI from callbacks, avoid threading. self.connection!.start(queue: .main) return true } func fail(message: String) { // We cannot pass a message along with cancellation state transition. self.onFailed?(message) self.connection!.cancel() } func onStateChange(to: NWConnection.State) { switch to { case .waiting(let error): // This is most likely fatal, despite what the documentation says. self.fail(message: error.localizedDescription) case .ready: self.onConnected?() case .failed(let error): self.onFailed?(error.localizedDescription) default: return } } func processCallbacks( commandSeq: UInt32, error: String, response: RelayResponseData?) { if let callback = self.commandCallbacks[commandSeq] { callback(error, response) } else if !error.isEmpty { Logger().warning("Unawaited response: \(error)") } else { Logger().warning("Unawaited response") } self.commandCallbacks.removeValue(forKey: commandSeq) // We don't particularly care about wraparound issues. for (seq, callback) in self.commandCallbacks where seq < commandSeq { callback("No response.", nil) self.commandCallbacks.removeValue(forKey: seq) } } func process(message: RelayEventMessage) { switch message.data { case let data as RelayEventDataError: self.processCallbacks( commandSeq: data.commandSeq, error: data.error, response: nil) case let data as RelayEventDataResponse: self.processCallbacks( commandSeq: data.commandSeq, error: "", response: data.data) default: self.onEvent?(message) } } func receiveMessage(length: Int) { self.connection!.receive( minimumIncompleteLength: length, maximumLength: length) { (content, context, isComplete, error) in guard let content = content else { // TODO(p): Do we need to bring it down explicitly on error? if isComplete { self.fail(message: "Connection closed.") } return } var r = RelayReader(data: content) do { self.process(message: try RelayEventMessage(from: &r)) self.receiveFrame() } catch { self.fail(message: "Deserialization failed.") } } } func receiveFrame() { let length = MemoryLayout.size self.connection!.receive( minimumIncompleteLength: length, maximumLength: length) { (content, context, isComplete, error) in guard let content = content else { // TODO(p): Do we need to bring it down explicitly on error? if isComplete { self.fail(message: "Connection closed.") } return } var r = RelayReader(data: content) do { let len: UInt32 = try r.read() if let len = Int(exactly: len) { self.receiveMessage(length: len) } else { self.fail(message: "Frame length overflow.") } } catch { self.fail(message: "Deserialization failed.") } } } func send(data: RelayCommandData, callback: Callback? = nil) { self.commandSeq += 1 let m = RelayCommandMessage(commandSeq: self.commandSeq, data: data) if let callback = callback { self.commandCallbacks[m.commandSeq] = callback } var w = RelayWriter() m.encode(to: &w) var prefix = RelayWriter() prefix.append(UInt32(w.data.count)) self.connection!.batch() { self.connection!.send(content: prefix.data, completion: .contentProcessed({ error in })) self.connection!.send(content: w.data, completion: .contentProcessed({ error in })) } } } // --- State ------------------------------------------------------------------- class Server { var state: RelayServerState = .disconnected var user: String = "" var userModes: String = "" } struct BufferLine { var leaked: Bool = false var isUnimportant: Bool = false var isHighlight: Bool = false var rendition: RelayRendition = .bare var when: UInt64 = 0 var text = NSAttributedString() } class Buffer { var bufferName: String = "" var hideUnimportant: Bool = false var kind: RelayBufferKind = .global var serverName: String = "" var lines: Array = [] // Channel: var topic = NSAttributedString() var modes: String = "" // Stats: var newMessages: UInt32 = 0 var newUnimportantMessages: UInt32 = 0 var highlighted: Bool = false // Input: var input: String = "" var inputSelection: NSRange? = nil var history: Array = [] var historyAt: Int = 0 } var relayRPC = RelayRPC() var relayBuffers: Array = [] var relayBufferCurrent: String = "" var relayBufferLast: String = "" var relayServers: Dictionary = [:] // --- Buffers ----------------------------------------------------------------- func bufferBy(name: String) -> Buffer? { return relayBuffers.first(where: { b in b.bufferName == name }) } func bufferActivate(name: String) { let activate = RelayCommandDataBufferActivate(bufferName: name) relayRPC.send(data: activate) } func bufferToggleUnimportant(name: String) { let toggle = RelayCommandDataBufferToggleUnimportant(bufferName: name) relayRPC.send(data: toggle) } // --- GUI --------------------------------------------------------------------- let app = NSApplication.shared let menu = NSMenu() app.mainMenu = menu let applicationMenuItem = NSMenuItem() menu.addItem(applicationMenuItem) let applicationMenu = NSMenu() applicationMenuItem.submenu = applicationMenu applicationMenu.addItem(NSMenuItem(title: "Quit " + projectName, action: #selector(app.terminate), keyEquivalent: "q")) let bufferMenuItem = NSMenuItem() menu.addItem(bufferMenuItem) let bufferMenu = NSMenu(title: "Buffer") bufferMenuItem.submenu = bufferMenu let uiStackView = NSStackView() uiStackView.orientation = .vertical uiStackView.spacing = 0 uiStackView.alignment = .leading func pushWithMargins(view: NSView) { let box = NSStackView() box.orientation = .horizontal box.edgeInsets = NSEdgeInsetsMake(4, 8, 4, 8) box.addArrangedSubview(view) uiStackView.addArrangedSubview(box) } // TODO(p): Consider replacing with NSTextView, // to avoid font changes when selected. let uiTopic = NSTextField(wrappingLabelWithString: "") uiTopic.isEditable = false uiTopic.isBezeled = false uiTopic.drawsBackground = false // Otherwise clicking it removes string attributes. uiTopic.allowsEditingTextAttributes = true pushWithMargins(view: uiTopic) let uiSeparator1 = NSBox() uiSeparator1.boxType = .separator uiStackView.addArrangedSubview(uiSeparator1) let uiBufferList = NSTableView() uiBufferList.addTableColumn( NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Buffer name"))) uiBufferList.headerView = nil uiBufferList.style = .plain uiBufferList.rowSizeStyle = .default uiBufferList.allowsEmptySelection = false let uiBufferListScroll = NSScrollView() uiBufferListScroll.setFrameSize(CGSize(width: 1, height: 1)) uiBufferListScroll.documentView = uiBufferList uiBufferListScroll.hasVerticalScroller = true uiBufferListScroll.verticalScroller?.refusesFirstResponder = true let uiBuffer = NSTextView() uiBuffer.isEditable = false uiBuffer.focusRingType = .default // Otherwise it skips a lot when scrolling. uiBuffer.layoutManager?.allowsNonContiguousLayout = false uiBuffer.autoresizingMask = [.width, .height] let uiBufferScroll = NSScrollView() uiBufferScroll.setFrameSize(CGSize(width: 2, height: 1)) uiBufferScroll.documentView = uiBuffer uiBufferScroll.hasVerticalScroller = true uiBufferScroll.verticalScroller?.refusesFirstResponder = true let uiSplitView = NSSplitView() uiSplitView.isVertical = true uiSplitView.dividerStyle = .thin uiSplitView.addArrangedSubview(uiBufferListScroll) uiSplitView.addArrangedSubview(uiBufferScroll) uiStackView.addArrangedSubview(uiSplitView) NSLayoutConstraint.activate([ uiSplitView.leadingAnchor.constraint( equalTo: uiStackView.leadingAnchor), uiSplitView.trailingAnchor.constraint( equalTo: uiStackView.trailingAnchor), ]) let uiSeparator2 = NSBox() uiSeparator2.boxType = .separator uiStackView.addArrangedSubview(uiSeparator2) let uiStatus = NSTextField() uiStatus.isEditable = false uiStatus.isBezeled = false uiStatus.drawsBackground = false uiStatus.stringValue = "Connecting..." pushWithMargins(view: uiStatus) let uiSeparator3 = NSBox() uiSeparator3.boxType = .separator uiStackView.addArrangedSubview(uiSeparator3) let uiBottomStackView = NSStackView() uiBottomStackView.orientation = .horizontal pushWithMargins(view: uiBottomStackView) let uiPrompt = NSTextField() uiPrompt.isEditable = false uiPrompt.isBezeled = false uiPrompt.drawsBackground = false uiPrompt.isHidden = true uiBottomStackView.addArrangedSubview(uiPrompt) let uiInput = NSTextField() uiBottomStackView.addArrangedSubview(uiInput) let uiWindow = NSWindow( contentRect: NSMakeRect(0, 0, 640, 480), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: true) uiWindow.title = projectName uiWindow.contentView = uiStackView // --- Current buffer ---------------------------------------------------------- func bufferAtBottom() -> Bool { // TODO(p): Actually implement. // Consider uiBufferScroll.verticalScroller.floatValue. return true } func bufferScrollToBottom() { uiBuffer.scrollToEndOfDocument(nil) } // --- UI state refresh -------------------------------------------------------- func refreshIcon() { // TODO(p): Perhaps adjust NSApplication.applicationIconImage. } func refreshTopic(topic: NSAttributedString) { uiTopic.attributedStringValue = topic } func refreshBufferList() { uiBufferList.reloadData() } func serverStateToString(state: RelayServerState) -> String { switch state { case .disconnected: return "disconnected" case .connecting: return "connecting" case .connected: return "connected" case .registered: return "registered" case .disconnecting: return "disconnecting" } } func refreshPrompt() { var prompt = String() if let b = bufferBy(name: relayBufferCurrent) { if let server = relayServers[b.serverName] { prompt = server.user if !server.userModes.isEmpty { prompt += "(\(server.userModes))" } if prompt.isEmpty { prompt = "(\(serverStateToString(state: server.state)))" } } } if prompt.isEmpty { uiPrompt.isHidden = true } else { uiPrompt.isHidden = false uiPrompt.stringValue = prompt } } func refreshStatus() { var status = relayBufferCurrent if let b = bufferBy(name: relayBufferCurrent) { if !b.modes.isEmpty { status += "(+\(b.modes))" } if b.hideUnimportant { status += "" } } else { status = "Synchronizing..." } // XXX: This indicator should probably be on the right side of the window. if !bufferAtBottom() { status += " ^" } uiStatus.stringValue = status } // --- Buffer output ----------------------------------------------------------- func convertColor(color: Int16) -> NSColor { let base16: [UInt16] = [ 0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc, 0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff, ] if color < 16 { let r = CGFloat(0xf & (base16[Int(color)] >> 8)) / 0xf let g = CGFloat(0xf & (base16[Int(color)] >> 4)) / 0xf let b = CGFloat(0xf & (base16[Int(color)] >> 0)) / 0xf return NSColor(red: r, green: g, blue: b, alpha: 1) } if color >= 216 { let g = CGFloat(8 + (color - 216) * 10) / 0xff return NSColor(white: g, alpha: 1) } let i = color - 16 let r = (i / 36) let g = (i / 6) % 6 let b = (i % 6) let rr = r > 0 ? CGFloat(55 + 40 * r) / 0xff : 0 let gg = g > 0 ? CGFloat(55 + 40 * g) / 0xff : 0 let bb = b > 0 ? CGFloat(55 + 40 * b) / 0xff : 0 return NSColor(red: rr, green: gg, blue: bb, alpha: 1) } func convertItemFormatting(item: RelayItemData, attrs: inout [NSAttributedString.Key : Any], inverse: inout Bool) { switch item { case is RelayItemDataReset: attrs.removeAll() inverse = false case is RelayItemDataFlipBold: // TODO(p): Need to select a font: applyFontTraits(_:range:)? break case is RelayItemDataFlipItalic: // TODO(p): Need to select a font: applyFontTraits(_:range:)? break case is RelayItemDataFlipUnderline: if attrs[.underlineStyle] != nil { attrs.removeValue(forKey: .underlineStyle) } else { attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue } case is RelayItemDataFlipCrossedOut: if attrs[.strikethroughStyle] != nil { attrs.removeValue(forKey: .strikethroughStyle) } else { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } case is RelayItemDataFlipInverse: inverse = !inverse case is RelayItemDataFlipMonospace: // TODO(p): Need to select a font: applyFontTraits(_:range:)? break case let data as RelayItemDataFgColor: if data.color < 0 { attrs.removeValue(forKey: .foregroundColor) } else { attrs[.foregroundColor] = convertColor(color: data.color) } case let data as RelayItemDataBgColor: if data.color < 0 { attrs.removeValue(forKey: .backgroundColor) } else { attrs[.backgroundColor] = convertColor(color: data.color) } default: return } } func convertItems(items: [RelayItemData]) -> NSAttributedString { let result = NSMutableAttributedString() var attrs = [NSAttributedString.Key : Any]() var inverse = false for item in items { guard let text = item as? RelayItemDataText else { convertItemFormatting(item: item, attrs: &attrs, inverse: &inverse) continue } // TODO(p): Handle inverse text. result.append(NSAttributedString(string: text.text, attributes: attrs)) } if let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) { for m in detector.matches( in: result.string, range: NSMakeRange(0, result.length)) { guard let url = m.url else { continue } let raw = (result.string as NSString).substring(with: m.range) if raw.contains("://") { result.addAttribute(.link, value: url, range: m.range) } } } return result } func convertBufferLine(line: RelayEventDataBufferLine) -> BufferLine { var bl = BufferLine() bl.isUnimportant = line.isUnimportant bl.isHighlight = line.isHighlight bl.rendition = line.rendition bl.when = line.when bl.text = convertItems(items: line.items) return bl } func bufferPrintLine(line: BufferLine) { guard let ts = uiBuffer.textStorage else { return } let current = Date(timeIntervalSince1970: Double(line.when / 1000)) // TODO(p): Print date changes. if ts.length != 0 { ts.append(NSAttributedString(string: "\n")) } let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" ts.append(NSAttributedString( string: formatter.string(from: current), attributes: [ .foregroundColor: NSColor(white: 0xbb / 0xff, alpha: 1), .backgroundColor: NSColor(white: 0xf8 / 0xff, alpha: 1), .font: NSFont.monospacedDigitSystemFont( ofSize: uiBuffer.font!.pointSize, weight: .regular) ])) ts.append(NSAttributedString(string: " ")) let prefix = NSMutableAttributedString() var foreground: NSColor? = nil switch line.rendition { case .bare: break case .indent: prefix.mutableString.append(" ") case .status: prefix.mutableString.append(" - ") case .error: prefix.mutableString.append("=!= ") foreground = NSColor.red case .join: prefix.mutableString.append("--> ") foreground = NSColor(red: 0, green: 0.5, blue: 0, alpha: 1) case .part: prefix.mutableString.append("<-- ") foreground = NSColor(red: 0.5, green: 0, blue: 0, alpha: 1) case .action: prefix.mutableString.append(" * ") foreground = NSColor(red: 0.5, green: 0, blue: 0, alpha: 1) } if let color = foreground { prefix.addAttribute(.foregroundColor, value: color, range: NSMakeRange(0, prefix.length)) } // FIXME: Fixed pitch doesn't actually work. prefix.applyFontTraits(.boldFontMask, range: NSMakeRange(0, prefix.length)) prefix.applyFontTraits(.fixedPitchFontMask, range: NSMakeRange(0, prefix.length)) if line.leaked { let deattributed = NSMutableAttributedString(attributedString: prefix) deattributed.append(line.text) let whole = NSMakeRange(0, deattributed.length) deattributed.removeAttribute(.backgroundColor, range: whole) deattributed.addAttributes( [.foregroundColor: NSColor(white: 0.5, alpha: 1)], range: whole) ts.append(deattributed) } else { ts.append(prefix) ts.append(line.text) } } func bufferPrintSeparator() { guard let ts = uiBuffer.textStorage else { return } if ts.length != 0 { ts.append(NSAttributedString(string: "\n")) } // TODO(p): Figure out if we can't add an actual horizontal line. ts.append(NSAttributedString( string: "---", attributes: [.foregroundColor: NSColor.controlAccentColor])) } func refreshBuffer(b: Buffer) { // TODO(p): See if we can pause updating. // If not, consider updating textStorage atomically. if let ts = uiBuffer.textStorage { ts.setAttributedString(NSAttributedString()) } var i: Int = 0 let markBefore = b.lines.count - Int(b.newMessages) - Int(b.newUnimportantMessages) for line in b.lines { if i == markBefore { bufferPrintSeparator() } if !line.isUnimportant || !b.hideUnimportant { bufferPrintLine(line: line) } i += 1 } // TODO(p): Output any trailing date change. // FIXME: When the topic wraps, this doesn't scroll correctly. bufferScrollToBottom() } // --- Event processing -------------------------------------------------------- relayRPC.onConnected = { let hello = RelayCommandDataHello(version: UInt32(relayVersion)) relayRPC.send(data: hello) } relayRPC.onFailed = { error in let alert = NSAlert() alert.messageText = "Relay connection failed" alert.informativeText = error alert.addButton(withTitle: "OK") alert.runModal() exit(EXIT_FAILURE) } func onBufferLine(b: Buffer, m: RelayEventDataBufferLine) { // Initial sync: skip all other processing, let highlights be. guard let bc = bufferBy(name: relayBufferCurrent) else { b.lines.append(convertBufferLine(line: m)) return } let display = (!m.isUnimportant || !bc.hideUnimportant) && (b.bufferName == relayBufferCurrent || m.leakToActive) let toBottom = display && bufferAtBottom() // TODO(p): Once the log view is implemented, replace the "true" here. let visible = display && toBottom && !uiWindow.isMiniaturized && true let separate = display && !visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0 var line = convertBufferLine(line: m) if !(visible || m.leakToActive) || b.newMessages != 0 || b.newUnimportantMessages != 0 { if line.isUnimportant || m.leakToActive { b.newUnimportantMessages += 1 } else { b.newMessages += 1 } } b.lines.append(line) if m.leakToActive { line.leaked = true bc.lines.append(line) if !visible || bc.newMessages != 0 || bc.newUnimportantMessages != 0 { if line.isUnimportant { bc.newUnimportantMessages += 1 } else { bc.newMessages += 1 } } } if separate { bufferPrintSeparator() } if display { bufferPrintLine(line: line) } if toBottom { bufferScrollToBottom() } if line.isHighlight || (!visible && !line.isUnimportant && b.kind == .privateMessage) { NSSound.beep() if !visible { b.highlighted = true refreshIcon() } } refreshBufferList() } relayRPC.onEvent = { message in Logger().debug("Processing message \(message.eventSeq)") switch message.data { case _ as RelayEventDataPing: let pong = RelayCommandDataPingResponse(eventSeq: message.eventSeq) relayRPC.send(data: pong) case let data as RelayEventDataBufferLine: guard let b = bufferBy(name: data.bufferName) else { break } onBufferLine(b: b, m: data) case let data as RelayEventDataBufferUpdate: let b: Buffer if let buffer = bufferBy(name: data.bufferName) { b = buffer } else { b = Buffer() b.bufferName = data.bufferName relayBuffers.append(b) refreshBufferList() } let hidingToggled = b.hideUnimportant != data.hideUnimportant b.hideUnimportant = data.hideUnimportant b.kind = data.context.kind b.serverName.removeAll() switch data.context { case let context as RelayBufferContextServer: b.serverName = context.serverName case let context as RelayBufferContextChannel: b.serverName = context.serverName b.modes = context.modes b.topic = convertItems(items: context.topic) case let context as RelayBufferContextPrivateMessage: b.serverName = context.serverName default: break } if b.bufferName == relayBufferCurrent { refreshTopic(topic: b.topic) refreshStatus() if hidingToggled { refreshBuffer(b: b) } } case let data as RelayEventDataBufferStats: guard let b = bufferBy(name: data.bufferName) else { break } b.newMessages = data.newMessages b.newUnimportantMessages = data.newUnimportantMessages b.highlighted = data.highlighted refreshIcon() case let data as RelayEventDataBufferRename: guard let b = bufferBy(name: data.bufferName) else { break } b.bufferName = data.new if b.bufferName == relayBufferCurrent { relayBufferCurrent = data.new refreshStatus() } refreshBufferList() if b.bufferName == relayBufferLast { relayBufferLast = data.new } case let data as RelayEventDataBufferRemove: guard let b = bufferBy(name: data.bufferName) else { break } relayBuffers.removeAll(where: { $0 === b }) refreshBufferList() refreshIcon() case let data as RelayEventDataBufferActivate: let old = bufferBy(name: relayBufferCurrent) relayBufferLast = relayBufferCurrent relayBufferCurrent = data.bufferName guard let b = bufferBy(name: data.bufferName) else { break } if let old = old { old.newMessages = 0 old.newUnimportantMessages = 0 old.highlighted = false old.input = uiInput.stringValue // As in the textShouldBeginEditing delegate method. if let editor = uiInput.currentEditor() { old.inputSelection = editor.selectedRange } old.historyAt = old.history.count } // TODO(p): Disable log display, once implemented. if !uiWindow.isMiniaturized { b.highlighted = false } if let i = relayBuffers.firstIndex(where: { $0 === b }) { uiBufferList.selectRowIndexes( IndexSet(integer: i), byExtendingSelection: false) } refreshIcon() refreshTopic(topic: b.topic) refreshBuffer(b: b) refreshPrompt() refreshStatus() uiInput.stringValue = b.input uiWindow.makeFirstResponder(uiInput) if let editor = uiInput.currentEditor() { // As in the textShouldEndEditing delegate method. if let selection = b.inputSelection { editor.selectedRange = selection } else { editor.selectedRange = NSMakeRange(editor.string.count, 0) } } case let data as RelayEventDataBufferInput: guard let b = bufferBy(name: data.bufferName) else { break } if b.historyAt == b.history.count { b.historyAt += 1 } b.history.append(data.text) case let data as RelayEventDataBufferClear: guard let b = bufferBy(name: data.bufferName) else { break } b.lines.removeAll() if b.bufferName == relayBufferCurrent { refreshBuffer(b: b) } case let data as RelayEventDataServerUpdate: let s: Server if let server = relayServers[data.serverName] { s = server } else { s = Server() relayServers[data.serverName] = s } s.state = data.data.state s.user.removeAll() s.userModes.removeAll() if let registered = data.data as? RelayServerDataRegistered { s.user = registered.user s.userModes = registered.userModes } refreshPrompt() case let data as RelayEventDataServerRename: relayServers[data.new] = relayServers[data.serverName] relayServers.removeValue(forKey: data.serverName) case let data as RelayEventDataServerRemove: relayServers.removeValue(forKey: data.serverName) default: return } } // --- Input line -------------------------------------------------------------- struct InputStamp: Equatable { let input: String let selection: NSRange init?() { guard let textView = uiInput.currentEditor() as? NSTextView else { return nil } self.input = textView.string self.selection = textView.selectedRange() } } class InputDelegate: NSObject, NSTextFieldDelegate { func inputSubmit() -> Bool { guard let b = bufferBy(name: relayBufferCurrent) else { return false } let input = RelayCommandDataBufferInput( bufferName: b.bufferName, text: uiInput.stringValue) // Buffer.history[Buffer.history.count] is virtual, // and is represented either by edit contents when it's currently // being edited, or by Buffer.input in all other cases. b.history.append(uiInput.stringValue) b.historyAt = b.history.count uiInput.stringValue = "" relayRPC.send(data: input) return true } func inputComplete(state: InputStamp, error: String, data: RelayResponseDataBufferComplete?) { guard let data = data else { NSSound.beep() Logger().warning("\(error)") return } guard let textView = uiInput.currentEditor() as? NSTextView else { return } guard let preceding = String(state.input.utf8.prefix(Int(data.start))) else { return } if var insert = data.completions.first { if data.completions.count == 1 { insert += " " } textView.insertText(insert, replacementRange: NSMakeRange( preceding.count, NSMaxRange(state.selection) - preceding.count)) } if data.completions.count != 1 { NSSound.beep() } // TODO(p): Show all completion options. // Cocoa text completion isn't useful, because it searches for word // boundaries on its own (otherwise see NSControlTextEditingDelegate). } func inputComplete(textView: NSTextView) -> Bool { // TODO(p): Also add an increasing counter to the stamp. guard let state = InputStamp() else { return false } if state.selection.length != 0 { return false } let prefix = state.input.prefix(state.selection.location) let complete = RelayCommandDataBufferComplete( bufferName: relayBufferCurrent, text: state.input, position: UInt32(prefix.utf8.count)) relayRPC.send(data: complete) { (error, data) in if state != InputStamp() { return } self.inputComplete(state: state, error: error, data: data as? RelayResponseDataBufferComplete) } return true } func inputUp(textView: NSTextView) -> Bool { guard let b = bufferBy(name: relayBufferCurrent) else { return false } if b.historyAt < 1 { return false } if b.historyAt == b.history.count { b.input = uiInput.stringValue } b.historyAt -= 1 uiInput.stringValue = b.history[b.historyAt] textView.selectedRange = NSMakeRange(textView.string.count, 0) return true } func inputDown(textView: NSTextView) -> Bool { guard let b = bufferBy(name: relayBufferCurrent) else { return false } if b.historyAt >= b.history.count { return false } b.historyAt += 1 if b.historyAt == b.history.count { uiInput.stringValue = b.input } else { uiInput.stringValue = b.history[b.historyAt] } textView.selectedRange = NSMakeRange(textView.string.count, 0) return true } func control(_ control: NSControl, textShouldBeginEditing fieldEditor: NSText) -> Bool { guard let b = bufferBy(name: relayBufferCurrent) else { return false } if let selection = b.inputSelection { fieldEditor.selectedRange = selection } else { fieldEditor.selectedRange = NSMakeRange(fieldEditor.string.count, 0) } return true } func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool { guard let b = bufferBy(name: relayBufferCurrent) else { return true } b.inputSelection = fieldEditor.selectedRange return true } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { // TODO(p): Emacs-style cursor movement shortcuts. // TODO(p): Once the log is implemented, scroll that if visible. var success = true switch commandSelector { case #selector(NSStandardKeyBindingResponding.insertTab(_:)): success = self.inputComplete(textView: textView) case #selector(NSStandardKeyBindingResponding.insertNewline(_:)): success = self.inputSubmit() case #selector(NSStandardKeyBindingResponding.scrollPageUp(_:)): uiBuffer.doCommand(by: commandSelector) case #selector(NSStandardKeyBindingResponding.scrollPageDown(_:)): uiBuffer.doCommand(by: commandSelector) case #selector(NSStandardKeyBindingResponding.moveUp(_:)): success = self.inputUp(textView: textView) case #selector(NSStandardKeyBindingResponding.moveDown(_:)): success = self.inputDown(textView: textView) default: return false } if !success { NSSound.beep() } return true } } // --- General UI -------------------------------------------------------------- class BufferListDataSource: NSObject, NSTableViewDataSource { func numberOfRows(in tableView: NSTableView) -> Int { return relayBuffers.count } func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? { let b = relayBuffers[row] let result = NSMutableAttributedString(string: b.bufferName) if b.bufferName != relayBufferCurrent && b.newMessages != 0 { result.mutableString.append(" (\(b.newMessages))") result.applyFontTraits(.boldFontMask, range: NSMakeRange(0, result.length)) } if b.highlighted { result.addAttribute(.foregroundColor, value: NSColor.controlAccentColor, range: NSMakeRange(0, result.length)) } return result } } class BufferListDelegate: NSObject, NSTableViewDelegate { func tableView(_ tableView: NSTableView, shouldEdit: NSTableColumn?, row: Int) -> Bool { return false } func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { // The framework would select a row during synchronization. if !relayBufferCurrent.isEmpty && row < relayBuffers.count { bufferActivate(name: relayBuffers[row].bufferName) } return false } } class ApplicationDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // We need to call them from here, // or the menu wouldn't be clickable right after activation. app.setActivationPolicy(.regular) app.activate(ignoringOtherApps: true) } } class WindowDelegate: NSObject, NSWindowDelegate { func windowWillClose(_ notification: Notification) { app.terminate(nil) } func windowDidDeminiaturize(_ notification: Notification) { guard let b = bufferBy(name: relayBufferCurrent) else { return } b.highlighted = false refreshIcon() refreshBufferList() } // Buffer indexes rotated to start after the current buffer. func rotatedBuffers() -> Array { guard let i = relayBuffers.firstIndex( where: { $0.bufferName == relayBufferCurrent }) else { return relayBuffers } let start = i + 1 return Array(relayBuffers[start...] + relayBuffers[.. 1 { bufferActivate(name: rotated[rotated.count - 2].bufferName) } } @objc func actionNextBuffer() { if let following = self.rotatedBuffers().first { bufferActivate(name: following.bufferName) } } @objc func actionSwitchBuffer() { if !relayBufferLast.isEmpty { bufferActivate(name: relayBufferLast) } } @objc func actionGotoHighlight() { for b in rotatedBuffers() { if b.highlighted { bufferActivate(name: b.bufferName) return } } } @objc func actionGotoActivity() { for b in rotatedBuffers() { if b.newMessages != 0 { bufferActivate(name: b.bufferName) return } } } @objc func actionToggleUnimportant() { if let b = bufferBy(name: relayBufferCurrent) { bufferToggleUnimportant(name: b.bufferName) } } } // --- Accelerators ------------------------------------------------------------ func pushAccelerator(_ title: String, _ action: Selector, _ keyEquivalent: String, _ modifiers: NSEvent.ModifierFlags, hidden: Bool = false) { let item = NSMenuItem( title: title, action: action, keyEquivalent: keyEquivalent) item.keyEquivalentModifierMask = modifiers // isAlternate doesn't really cover our needs. if hidden { item.isHidden = true item.allowsKeyEquivalentWhenHidden = true } bufferMenu.addItem(item) } pushAccelerator("Previous buffer", #selector(WindowDelegate.actionPreviousBuffer), "p", [.control]) pushAccelerator("Next buffer", #selector(WindowDelegate.actionNextBuffer), "n", [.control]) pushAccelerator("Previous buffer", #selector(WindowDelegate.actionPreviousBuffer), String(UnicodeScalar(NSF5FunctionKey)!), [], hidden: true) pushAccelerator("Next buffer", #selector(WindowDelegate.actionNextBuffer), String(UnicodeScalar(NSF6FunctionKey)!), [], hidden: true) pushAccelerator("Previous buffer", #selector(WindowDelegate.actionPreviousBuffer), String(UnicodeScalar(NSPageUpFunctionKey)!), [.control], hidden: true) pushAccelerator("Next buffer", #selector(WindowDelegate.actionNextBuffer), String(UnicodeScalar(NSPageDownFunctionKey)!), [.control], hidden: true) // Let's add macOS browser shortcuts for good measure. pushAccelerator("Previous buffer", #selector(WindowDelegate.actionPreviousBuffer), "[", [.shift, .command], hidden: true) pushAccelerator("Next buffer", #selector(WindowDelegate.actionNextBuffer), "]", [.shift, .command], hidden: true) pushAccelerator("Switch buffer", #selector(WindowDelegate.actionSwitchBuffer), "\t", [.control]) // TODO(p): Remove .command, and ignore these with the right Option key. bufferMenu.addItem(NSMenuItem.separator()) pushAccelerator("Go to highlight", #selector(WindowDelegate.actionGotoHighlight), "!", [.command, .option]) pushAccelerator("Go to activity", #selector(WindowDelegate.actionGotoActivity), "a", [.command, .option]) bufferMenu.addItem(NSMenuItem.separator()) pushAccelerator("Toggle unimportant", #selector(WindowDelegate.actionToggleUnimportant), "H", [.command, .option]) // --- Delegation setup -------------------------------------------------------- let uiInputDelegate = InputDelegate() uiInput.delegate = uiInputDelegate let uiBufferListDataSource = BufferListDataSource() uiBufferList.dataSource = uiBufferListDataSource let uiBufferListDelegate = BufferListDelegate() uiBufferList.delegate = uiBufferListDelegate let appDelegate = ApplicationDelegate() app.delegate = appDelegate let uiWindowDelegate = WindowDelegate() uiWindow.delegate = uiWindowDelegate // --- Startup ----------------------------------------------------------------- // TODO(p): Ideally, we would show a dialog to enter this information. let defaults = UserDefaults.standard var relayHost: String? = defaults.string(forKey: "relayHost") var relayPort: String? = defaults.string(forKey: "relayPort") if CommandLine.arguments.count >= 3 { relayHost = CommandLine.arguments[1] relayPort = CommandLine.arguments[2] } if relayHost == nil || relayPort == nil { CFUserNotificationDisplayAlert( 0, kCFUserNotificationStopAlertLevel, nil, nil, nil, "\(projectName): Usage error" as CFString, ("The relay address and port either need to be stored " + "in your user defaults, or passed on the command line.") as CFString, nil, nil, nil, nil) exit(EXIT_FAILURE) } if !relayRPC.connect(host: relayHost!, port: relayPort!) { CFUserNotificationDisplayAlert( 0, kCFUserNotificationStopAlertLevel, nil, nil, nil, "\(projectName): Usage error" as CFString, "Invalid relay address." as CFString, nil, nil, nil, nil) exit(EXIT_FAILURE) } uiWindow.center() uiWindow.makeFirstResponder(uiInput) uiWindow.makeKeyAndOrderFront(nil) app.run()