diff --git a/MacTerminal/ViewController.swift b/MacTerminal/ViewController.swift index 91033f60..cc9656d5 100644 --- a/MacTerminal/ViewController.swift +++ b/MacTerminal/ViewController.swift @@ -12,7 +12,6 @@ import SwiftTerm class ViewController: NSViewController, LocalProcessTerminalViewDelegate, NSUserInterfaceValidations { @IBOutlet var loggingMenuItem: NSMenuItem? - var changingSize = false var logging: Bool = false diff --git a/SwiftTerm/Sources/SwiftTerm/CharData.swift b/SwiftTerm/Sources/SwiftTerm/CharData.swift index a91a89c8..da44c13d 100644 --- a/SwiftTerm/Sources/SwiftTerm/CharData.swift +++ b/SwiftTerm/Sources/SwiftTerm/CharData.swift @@ -149,6 +149,45 @@ public struct Attribute: Equatable, Hashable { } } +/// TinyAtoms are 16-bit values that can be used to represent a string as a number +/// you create them by calling TinyAtom.lookup (String) and retrieve the +/// value using the `target` property. They are used to store the urls and any +/// additional parameter information in the OSC 8 scenario. +/// +/// This is kept to 16 bits for now, so that we keep the CharData to less than 15 bytes +/// it could in theory be changed to be 24 bits without much trouble +public struct TinyAtom { + var code: UInt16 + static var stringMap: [UInt16:String] = [:] + static var lastUsed: Int = 0 + static var empty = TinyAtom (code: 0) + + private init(code: UInt16) + { + self.code = code + } + + /// Returns the TinyAtom associated with the specified url, or nil if we ran out of space + public static func lookup (text: String) -> TinyAtom? { + let next = lastUsed + 1 + if next < UInt16.max { + stringMap [UInt16 (next)] = text + lastUsed = next + return TinyAtom (code: UInt16 (next)) + } + return nil + } + + /// Returns the target for the TinyAtom + public var target: String? { + get { + if code == 0 { + return nil + } + return TinyAtom.stringMap [code] + } + } +} /** * Stores a cell with both the character being displayed as well as the color attribute. * This uses an Int32 to store the value, if the value can not be encoded as a single Unicode.Scalar, @@ -169,7 +208,8 @@ public struct CharData { // Contains the index to character mapping, could be a plain array static var indexToCharMap: [Int32: Character] = [:] static var lastCharIndex: Int32 = (1 << 22)+1 - + + public static let defaultAttr = Attribute(fg: .defaultColor, bg: .defaultColor, style: .none) public static let invertedAttr = Attribute(fg: .defaultInvertedColor, bg: .defaultInvertedColor, style: .none) @@ -177,7 +217,12 @@ public struct CharData { var code: Int32 ///Contains the number of columns used by the `Character` stored in this `CharData` on the screen. - public var width: Int8 + public private(set) var width: Int8 + + // This contains an assigned key + var urlPayload: TinyAtom + + var unused: UInt8 // Purely here to align to 16 bytes /// The color and character attributes for the cell public var attribute: Attribute @@ -202,6 +247,8 @@ public struct CharData { } } width = Int8 (size) + urlPayload = TinyAtom.empty + unused = 0 } // Empty cell sets the code to zero @@ -210,6 +257,8 @@ public struct CharData { self.attribute = attribute code = 0 width = 1 + urlPayload = TinyAtom.empty + unused = 0 } public var isSimpleRune: Bool { @@ -218,6 +267,23 @@ public struct CharData { } } + /// Sets the Url token for the this CharData. + mutating public func setUrlPayload (atom: TinyAtom) + { + self.urlPayload = atom + } + + public func getPayload () -> String? + { + urlPayload.target + } + + public var hasUrl: Bool { + get { + return urlPayload.code != 0 + } + } + /// The `Null` character can be used when filling up parts of the screeb public static var Null : CharData = CharData (attribute: defaultAttr) diff --git a/SwiftTerm/Sources/SwiftTerm/Mac/MacTerminalView.swift b/SwiftTerm/Sources/SwiftTerm/Mac/MacTerminalView.swift index 7b70244c..69b6c4dc 100644 --- a/SwiftTerm/Sources/SwiftTerm/Mac/MacTerminalView.swift +++ b/SwiftTerm/Sources/SwiftTerm/Mac/MacTerminalView.swift @@ -11,11 +11,57 @@ import AppKit import CoreText import CoreGraphics + +public protocol TerminalViewDelegate: class { + /** + * The client code sending commands to the terminal has requested a new size for the terminal + * Applications that support this should call the `TerminalView.getOptimalFrameSize` + * to get the ideal frame size. + * + * This is needed for the rare cases where the remote client request 80 or 132 column displays, + * it is a rare feature and you most likely can ignore this request. + */ + func sizeChanged (source: TerminalView, newCols: Int, newRows: Int) + + /** + * Request to change the title of the terminal. + */ + func setTerminalTitle(source: TerminalView, title: String) + + /** + * Request that date be sent to the application running inside the terminal. + * - Parameter data: Slice of data that should be sent + */ + func send (source: TerminalView, data: ArraySlice) + + /** + * Invoked when the terminal has been scrolled and the new position is provided + * - Parameter position: the relative position that the code was scrolled to, a value between 0 and 1 + */ + func scrolled (source: TerminalView, position: Double) + + /** + * Invoked in response to the user clicking on a link, which is most likely a url, but is not + * mandatory, so custom implementations receive a string, and they can act on this as a way + * of communciating with the host if desired. The default implementation calls NSWorkspace.shared.open() + * on the URL. + * - Parameter source: the terminalview that called this method + * - Parameter link: the string that was encoded as a link by the client application, typically a url, + * but could be anything, and could be used to communicate by the embedded application and the host + * - Parameter params: the specification allows for key/value pairs to be provided, this contains the + * key and value pairs that were provided + */ + func requestOpenLink (source: TerminalView, link: String, params: [String:String]) +} + /** * TerminalView provides an AppKit front-end to the `Terminal` termininal emulator. * It is up to a subclass to either wire the terminal emulator to a remote terminal * via some socket, to an application that wants to run with terminal emulation, or * wiring this up to a pseudo-terminal. + * + * Users are notified of interesting events in their implementation of the `TerminalViewDelegate` + * methods - an instance must be provided to the constructor of `TerminalView`. */ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations { @@ -61,11 +107,12 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations setup(frame: self.bounds) } + /// Returns the underlying terminal emulator that the `TerminalView` is a view for public func getTerminal () -> Terminal { return terminal } - + func setup(frame rect: CGRect) { let baseFont: NSFont @@ -259,22 +306,26 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations userScrolling = false } + /// Scrolls the content of the terminal one page up public func pageUp() { scrollUp (lines: terminal.rows) } + /// Scrolls the content of the terminal one page down public func pageDown () { scrollDown (lines: terminal.rows) } + /// Scrolls up the content of the terminal the specified number of lines public func scrollUp (lines: Int) { let newPosition = max (terminal.buffer.yDisp - lines, 0) scrollTo (row: newPosition) } + /// Scrolls down the content of the terminal the specified number of lines public func scrollDown (lines: Int) { let newPosition = max (0, min (terminal.buffer.yDisp + lines, terminal.buffer.lines.count - terminal.rows)) @@ -331,7 +382,7 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations // // Given a vt100 attribute, return the NSAttributedString attributes used to render it // - func getAttributes (_ attribute: Attribute) -> [NSAttributedString.Key:Any]? + func getAttributes (_ attribute: Attribute, withUrl: Bool) -> [NSAttributedString.Key:Any]? { let flags = attribute.style var bg = attribute.bg @@ -348,7 +399,7 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations } } - if let result = attributes [attribute] { + if let result = withUrl ? urlAttributes [attribute] : attributes [attribute] { return result } @@ -379,13 +430,22 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations nsattr [.strikethroughColor] = fgColor nsattr [.strikethroughStyle] = NSUnderlineStyle.single.rawValue } - - attributes [attribute] = nsattr + if withUrl { + nsattr [.underlineStyle] = NSUnderlineStyle.single.rawValue + Int (CTUnderlineStyleModifiers.patternDot.rawValue) + nsattr [.underlineColor] = fgColor + + // Add to cache + urlAttributes [attribute] = nsattr + } else { + // Just add to cache + attributes [attribute] = nsattr + } return nsattr } // Attribute dictionary, maps a console attribute (color, flags) to the corresponding dictionary of attributes for an NSAttributedString var attributes: [Attribute: [NSAttributedString.Key:Any]] = [:] + var urlAttributes: [Attribute: [NSAttributedString.Key:Any]] = [:] // // Given a line of text with attributes, returns the NSAttributedString, suitable to be drawn @@ -394,22 +454,26 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations { let res = NSMutableAttributedString () var attr = Attribute.empty + var hasUrl = false var str = prefix for col in 0.. CTLine { - let attributedStringLine = attrStrBuffer [row] - let ctline = CTLineCreateWithAttributedString (attributedStringLine) - return ctline + let attributedStringLine = attrStrBuffer [row] + let ctline = CTLineCreateWithAttributedString (attributedStringLine) + return ctline } func characterOffset (atRow row: Int, col: Int) -> CGFloat { - let ctline = self.ctline (forRow: row) - return CTLineGetOffsetForStringIndex (ctline, col, nil) + let ctline = self.ctline (forRow: row) + return CTLineGetOffsetForStringIndex (ctline, col, nil) } // TODO: Clip here @@ -783,6 +847,7 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations scrollTo (row: terminal.buffer.yBase) } } + // // NSTextInputClient protocol implementation // @@ -807,7 +872,67 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations return true } } + + // Tracking object, maintained by `startTracking` and `deregisterTrackingInterest` + var tracking: NSTrackingArea? = nil + + // Turns on AppKit mouse event tracking - used both by the url highlighter and the mouse move, + // when the client application has set MouseMove.anyEvent + // + // Can be invoked multiple times, use the "deregisterTrackingInterest" method to turn it off + // which will take into account both the url highlighter state (which is bound to the command + // key being pressed) and the client requirements + func startTracking () + { + if tracking == nil { + tracking = NSTrackingArea (rect: frame, options: [.activeAlways, .mouseMoved, .mouseEnteredAndExited], owner: self, userInfo: [:]) + addTrackingArea(tracking!) + } + } + + // Can be invoked by both the keyboard handler monitoring the command key, and the + // mouse tracking system, only when both are off, this is turned off. + func deregisterTrackingInterest () + { + if commandActive == false && terminal.mouseMode != .anyEvent { + removeTrackingArea(tracking!) + tracking = nil + } + } + func turnOffUrlPreview () + { + if commandActive { + deregisterTrackingInterest() + removePreviewUrl() + print ("gone") + commandActive = false + } + } + + // If true, the Command key has been pressed + var commandActive = false + + // We monitor the flags changed to enable URL previews on mouse-hover like iTerm + // when the Command key is pressed. + public override func flagsChanged(with event: NSEvent) { + if event.modifierFlags.contains(.command){ + commandActive = true + startTracking() + if let payload = getPayload(for: event) { + previewUrl (payload: payload) + } + } else { + turnOffUrlPreview () + } + super.flagsChanged(with: event) + } + + public override func mouseExited(with event: NSEvent) { + turnOffUrlPreview() + super.mouseExited(with: event) + } + // // We capture a handful of keydown events and pre-process those, and then let // interpretKeyEvents do the rest of the work, that includes text-insertion, and @@ -1172,18 +1297,33 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations } } - private var didSelectionDrag: Bool = false + func getPayload (for event: NSEvent) -> String? + { + let hit = calculateMouseHit(with: event) + let cd = terminal.buffer.lines [terminal.buffer.yBase+hit.row][hit.col] + return cd.getPayload() + } + + var didSelectionDrag: Bool = false + public override func mouseUp(with event: NSEvent) { super.mouseUp(with: event) + if event.modifierFlags.contains(.command){ + if let payload = getPayload(for: event) { + if let (url, params) = urlAndParamsFrom(payload: payload) { + delegate?.requestOpenLink(source: self, link: url, params: params) + } + } + } if terminal.mouseMode.sendButtonRelease() { sharedMouseEvent(with: event) return } - let hit = calculateMouseHit(with: event) #if DEBUG - print ("Up at col=\(hit.col) row=\(hit.row) count=\(event.clickCount) selection.active=\(selection.active) didSelectionDrag=\(didSelectionDrag) ") + let hit = calculateMouseHit(with: event) + //print ("Up at col=\(hit.col) row=\(hit.row) count=\(event.clickCount) selection.active=\(selection.active) didSelectionDrag=\(didSelectionDrag) ") #endif didSelectionDrag = false @@ -1219,11 +1359,75 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations } } + + func tryUrlFont () -> NSFont + { + for x in ["Optima", "Helvetica", "Helvetica Neue"] { + if let font = NSFont (name: x, size: 12) { + return font + } + } + return NSFont.systemFont(ofSize: 12) + } + + // The payload contains the terminal data which is expected to be of the form + // params;URL, so we need to extract the second component, but we also assume that + // the input might be ill-formed, so we might return nil in that case + func urlAndParamsFrom (payload: String) -> (String, [String:String])? + { + let split = payload.split(separator: ";", maxSplits: Int.max, omittingEmptySubsequences: false) + if split.count > 1 { + let pairs = split [0].split (separator: ":") + var params: [String:String] = [:] + for p in pairs { + let kv = p.split (separator: "=") + if kv.count == 2 { + params [String (kv [0])] = String (kv[1]) + } + } + return (String (split [1]), params) + } + return nil + } + + var urlPreview: NSTextField? + func previewUrl (payload: String) + { + if let (url, params) = urlAndParamsFrom(payload: payload) { + if let up = urlPreview { + up.stringValue = url + up.sizeToFit() + } else { + let nup = NSTextField (string: url) + nup.isBezeled = false + nup.font = tryUrlFont () + nup.backgroundColor = defFgColor + nup.textColor = defBgColor + nup.sizeToFit() + nup.frame = CGRect (x: 0, y: 0, width: nup.frame.width, height: nup.frame.height) + addSubview(nup) + urlPreview = nup + } + } + } + + func removePreviewUrl () + { + if let urlPreview = self.urlPreview { + urlPreview.removeFromSuperview() + self.urlPreview = nil + } + } + public override func mouseMoved(with event: NSEvent) { - // TODO: Add tracking area + let hit = calculateMouseHit(with: event) + if commandActive { + if let payload = getPayload(for: event) { + previewUrl (payload: payload) + } + } if terminal.mouseMode.sendMotionEvent() { - let hit = calculateMouseHit(with: event) let flags = encodeMouseEvent(with: event) terminal.sendMotion(buttonFlags: flags, x: hit.col, y: hit.row) } @@ -1258,35 +1462,44 @@ public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations public override func resetCursorRects() { addCursorRect(bounds, cursor: .iBeam) } - - // Terminal.Delegate method implementation - public func isProcessTrusted() -> Bool { - true - } } extension TerminalView: TerminalDelegate { - public func showCursor(source: Terminal) { - // - } - - public func setTerminalTitle(source: Terminal, title: String) { - delegate?.setTerminalTitle(source: self, title: title) - } - - public func sizeChanged(source: Terminal) { - delegate?.sizeChanged(source: self, newCols: source.cols, newRows: source.rows) - updateScroller () - } - - public func setTerminalIconTitle(source: Terminal, title: String) { - // - } - - // Terminal.Delegate method implementation - public func windowCommand(source: Terminal, command: Terminal.WindowManipulationCommand) -> [UInt8]? { - return nil - } + public func isProcessTrusted(source: Terminal) -> Bool { + true + } + + public func mouseModeChanged(source: Terminal) { + if source.mouseMode == .anyEvent { + startTracking() + } else { + if terminal != nil { + deregisterTrackingInterest() + } + } + } + + public func showCursor(source: Terminal) { + // + } + + public func setTerminalTitle(source: Terminal, title: String) { + delegate?.setTerminalTitle(source: self, title: title) + } + + public func sizeChanged(source: Terminal) { + delegate?.sizeChanged(source: self, newCols: source.cols, newRows: source.rows) + updateScroller () + } + + public func setTerminalIconTitle(source: Terminal, title: String) { + // + } + + // Terminal.Delegate method implementation + public func windowCommand(source: Terminal, command: Terminal.WindowManipulationCommand) -> [UInt8]? { + return nil + } } @@ -1302,9 +1515,23 @@ private extension NSColor { } } -#endif - - extension NSAttributedString.Key { static let fullBackgroundColor: NSAttributedString.Key = .init("SwiftTerm_fullBackgroundColor") // NSColor, default nil: no background } + +// Default implementations for TerminalViewDelegate + +extension TerminalViewDelegate { + public func requestOpenLink (source: TerminalView, link: String, params: [String:String]) + { + if let fixedup = link.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { + if let url = NSURLComponents(string: fixedup) { + if let nested = url.url { + NSWorkspace.shared.open(nested) + } + } + } + } +} + +#endif diff --git a/SwiftTerm/Sources/SwiftTerm/Mac/TerminalViewDelegate.swift b/SwiftTerm/Sources/SwiftTerm/Mac/TerminalViewDelegate.swift deleted file mode 100644 index 0b63199d..00000000 --- a/SwiftTerm/Sources/SwiftTerm/Mac/TerminalViewDelegate.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// TerminalViewDelegate.swift -// -// -// Created by Marcin Krzyzanowski on 11/04/2020. -// - -public protocol TerminalViewDelegate: class { - /** - * The client code sending commands to the terminal has requested a new size for the terminal - * Applications that support this should call the `TerminalView.getOptimalFrameSize` - * to get the ideal frame size. - * - * This is needed for the rare cases where the remote client request 80 or 132 column displays, - * it is a rare feature and you most likely can ignore this request. - */ - func sizeChanged (source: TerminalView, newCols: Int, newRows: Int) - - /** - * Request to change the title of the terminal. - */ - func setTerminalTitle(source: TerminalView, title: String) - - /** - * The provided `data` needs to be sent to the application running inside the terminal - */ - func send (source: TerminalView, data: ArraySlice) - - /** - * Invoked when the terminal has been scrolled and the new position is provided - */ - func scrolled (source: TerminalView, position: Double) -} diff --git a/SwiftTerm/Sources/SwiftTerm/Terminal.swift b/SwiftTerm/Sources/SwiftTerm/Terminal.swift index 7c73bb58..d5550bc2 100644 --- a/SwiftTerm/Sources/SwiftTerm/Terminal.swift +++ b/SwiftTerm/Sources/SwiftTerm/Terminal.swift @@ -85,7 +85,14 @@ public protocol TerminalDelegate { * otherwise, return false. This is useful to run some applications that attempt to checksum the * contents of the screen (unit tests) */ - func isProcessTrusted () -> Bool + func isProcessTrusted (source: Terminal) -> Bool + + /** + * This method is invoked when the `mouseMode` property has changed, and gives the UI + * a chance to update any tracking capabilities that are required in the toolkit or no longer + * required to provide the events. + */ + func mouseModeChanged (source: Terminal) } /** @@ -246,7 +253,12 @@ open class Terminal { self == .vt200 || self == .buttonEventTracking || self == .anyEvent } } - public private(set) var mouseMode: MouseMode = .off + + public private(set) var mouseMode: MouseMode = .off { + didSet { + tdel.mouseModeChanged (source: self) + } + } // The next four variables determine whether setting/querying should be done using utf8 or latin1 // and whether the values should be set or queried using hex digits, rather than actual byte streams @@ -348,6 +360,8 @@ open class Terminal { xtermTitleSetHex = false xtermTitleQueryHex = false + + hyperLinkTracking = nil } // DCS $ q Pt ST @@ -1019,10 +1033,34 @@ open class Terminal { } } + var hyperLinkTracking: (start: Position, payload: String)? = nil + func oscHyperlink (_ data: ArraySlice) { - let str = String (bytes:data, encoding: .ascii) ?? "" - print ("This is the data I got \(str)") + let buffer = self.buffer + if data.count == 1 && data [data.startIndex] == 0x3b /* ; */ { + // We only had the terminator, so we can close ";" + if let hlt = hyperLinkTracking { + let str = hlt.payload + if let urlToken = TinyAtom.lookup (text: str) { + print ("Setting the text from \(hlt.start) to \(buffer.x) on line \(buffer.y+buffer.yBase) to \(str)") + + for y in hlt.start.row...(buffer.y+buffer.yBase) { + let line = buffer.lines [y] + let startCol = y == hlt.start.row ? hlt.start.col : 0 + let endCol = y == buffer.y ? buffer.x : (marginMode ? buffer.marginRight : cols-1) + for x in startCol...endCol { + var cd = line [x] + cd.setUrlPayload(atom: urlToken) + line [x] = cd + } + } + } + } + hyperLinkTracking = nil + } else { + hyperLinkTracking = (start: Position(col: buffer.x, row: buffer.y+buffer.yBase), payload: String (bytes:data, encoding: .ascii) ?? "") + } } func oscSetTextForeground (_ data: ArraySlice) @@ -1679,9 +1717,7 @@ open class Terminal { let rid = pars.count > 0 ? pars [0] : 1 let _ = pars.count > 1 ? pars [1] : 0 var result = "0000" - // Still need to imeplemnt the checksum here - // Which is just the sum of the rune values - if tdel.isProcessTrusted() && pars.count > 2 { + if tdel.isProcessTrusted(source: self) && pars.count > 2 { if let (top, left, bottom, right) = getRectangleFromRequest(pars [2...]) { for row in top...bottom { let line = buffer.lines [row+buffer.yBase] @@ -2157,6 +2193,7 @@ open class Terminal { charset = nil setgLevel (0) conformance = .vt500 + hyperLinkTracking = nil // MIGUEL TODO: // TODO: audit any new variables, those in setup might be useful