From 1c4343058da2a1e0c6d2fd87a9bde4ef4b378eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Eric=20Janouch?= Date: Wed, 30 Aug 2023 00:35:34 +0200 Subject: Add a Cocoa frontend for xC Some work remains to be done to get it to be even as good as the Win32 frontend, but it's generally usable. --- NEWS | 2 + README.adoc | 19 +- xM/CMakeLists.txt | 32 ++ xM/main.swift | 1372 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1421 insertions(+), 4 deletions(-) create mode 100644 xM/CMakeLists.txt create mode 100644 xM/main.swift diff --git a/NEWS b/NEWS index 8177a84..70f3849 100644 --- a/NEWS +++ b/NEWS @@ -33,6 +33,8 @@ * Added a Win32 frontend for xC called xW + * Added a Cocoa frontend for xC called xM + * Added a Go port of xD called xS diff --git a/README.adoc b/README.adoc index 4a82d33..6784e84 100644 --- a/README.adoc +++ b/README.adoc @@ -2,8 +2,9 @@ xK == 'xK' (chat kit) is an IRC software suite consisting of a daemon, bot, terminal -client, and web + Win32 frontends for the client. It's all you're ever going to -need for chatting, so long as you can make do with slightly minimalist software. +client, and web/Windows/macOS frontends for the client. It's all you're ever +going to need for chatting, so long as you can make do with slightly minimalist +software. They're all lean on dependencies, and offer a maximally permissive licence. @@ -140,13 +141,23 @@ endpoint as the third command line argument in this case. xW ~~ The Win32 frontend is a separate CMake subproject that should be compiled -using MinGW-w64. In order to run it, make a shortcut for the executable and -include the relay address in its _Target_ field: +using MinGW-w64. To avoid having to specify the relay address each time you +run it, create a shortcut for the executable and include the address in its +_Target_ field: C:\...\xW.exe 127.0.0.1 9000 It works reasonably well starting with Windows 7. +xM +~~ +The Cocoa frontend is a separate CMake subproject that requires Xcode to build. +It is currently not that usable. The relay address can either be passed on +the command line, or preset in the _defaults_ database: + + $ defaults write name.janouch.xM relayHost 127.0.0.1 + $ defaults write name.janouch.xM relayPort 9000 + Client Certificates ------------------- 'xC' will use the SASL EXTERNAL method to authenticate using the TLS client diff --git a/xM/CMakeLists.txt b/xM/CMakeLists.txt new file mode 100644 index 0000000..3386926 --- /dev/null +++ b/xM/CMakeLists.txt @@ -0,0 +1,32 @@ +# Swift language support +cmake_minimum_required (VERSION 3.15) + +file (READ ../xK-version project_version) +configure_file (../xK-version xK-version.tag COPYONLY) +string (STRIP "${project_version}" project_version) + +# There were two issues when building this from the main CMakeLists.txt: +# a) renaming main.swift to xM.swift requires removing top-level statements, +# b) there is a "redefinition of module 'FFI'" error. +project (xM VERSION "${project_version}" + DESCRIPTION "Cocoa frontend for xC" LANGUAGES Swift) + +set (root "${PROJECT_SOURCE_DIR}/..") +add_custom_command (OUTPUT xC-proto.swift + COMMAND env LC_ALL=C awk + -f ${root}/liberty/tools/lxdrgen.awk + -f ${root}/liberty/tools/lxdrgen-swift.awk + -v PrefixCamel=Relay + ${root}/xC.lxdr > xC-proto.swift + DEPENDS + ${root}/liberty/tools/lxdrgen.awk + ${root}/liberty/tools/lxdrgen-swift.awk + ${root}/xC.lxdr + COMMENT "Generating xC relay protocol code" VERBATIM) + +set (MACOSX_BUNDLE_GUI_IDENTIFIER name.janouch.${PROJECT_NAME}) + +# Other requirements: macOS 10.14 for Network, and macOS 11 for Logger. +set (CMAKE_Swift_LANGUAGE_VERSION 5) +add_executable (xM MACOSX_BUNDLE + main.swift ${PROJECT_BINARY_DIR}/xC-proto.swift) diff --git a/xM/main.swift b/xM/main.swift new file mode 100644 index 0000000..91e3499 --- /dev/null +++ b/xM/main.swift @@ -0,0 +1,1372 @@ +/* + * 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 + + refreshBufferList() + if b.bufferName == relayBufferCurrent { + relayBufferCurrent = data.new + refreshStatus() + } + 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() + } + + // 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() -- cgit v1.2.3