xK/xM/main.swift

1373 lines
36 KiB
Swift

/*
* 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 <p@janouch.name>
*
* 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<UInt32, Callback>()
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<UInt32>.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<BufferLine> = []
// 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<String> = []
var historyAt: Int = 0
}
var relayRPC = RelayRPC()
var relayBuffers: Array<Buffer> = []
var relayBufferCurrent: String = ""
var relayBufferLast: String = ""
var relayServers: Dictionary<String, Server> = [:]
// --- 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 += "<H>"
}
} 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<Buffer> {
guard let i = relayBuffers.firstIndex(
where: { $0.bufferName == relayBufferCurrent }) else {
return relayBuffers
}
let start = i + 1
return Array<Buffer>(relayBuffers[start...] + relayBuffers[..<start])
}
@objc func actionPreviousBuffer() {
let rotated = self.rotatedBuffers()
if rotated.count > 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()