Přemysl Eric Janouch
05a41b2629
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.
1374 lines
36 KiB
Swift
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()
|