2023-08-30 00:35:34 +02:00
|
|
|
/*
|
|
|
|
* 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()
|
|
|
|
}
|
2024-11-14 11:38:10 +01:00
|
|
|
refreshBufferList()
|
2023-08-30 00:35:34 +02:00
|
|
|
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()
|
2024-11-14 11:03:22 +01:00
|
|
|
refreshBufferList()
|
2023-08-30 00:35:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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()
|