xK/xM/main.swift
Přemysl Eric Janouch 05a41b2629
xA/xM/xW: refresh renamed buffers correctly
Rendering takes the current buffer into account,
so change its value before using it, not afterwards.

The order happened to not matter on at least Windows,
because we just queue a message.
2024-11-14 11:41:09 +01:00

1374 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
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<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()