From f1b0a74c84ceaf9a2cf66c7ff8bb1f8c1bc592ec Mon Sep 17 00:00:00 2001 From: Miguel de Icaza Date: Sun, 12 Apr 2020 00:04:45 -0400 Subject: [PATCH] =?UTF-8?q?Continue=20work=20on=20supporting=20URL=20paylo?= =?UTF-8?q?ads=20in=20the=20terminal.=20=20=20Needs=20to=20=E2=80=A6=20(#6?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Continue work on supporting URL payloads in the terminal. Needs to likely highlight the whole line before it is useful, some touchups on the font sizes and color for the hovers, and compare visually to iTerm * Fix after merge * Complete the url handling, fixed various pending bugs --- MacTerminal/ViewController.swift | 1 - SwiftTerm/Sources/SwiftTerm/CharData.swift | 70 +++- .../SwiftTerm/Mac/MacTerminalView.swift | 321 +++++++++++++++--- .../SwiftTerm/Mac/TerminalViewDelegate.swift | 33 -- SwiftTerm/Sources/SwiftTerm/Terminal.swift | 51 ++- 5 files changed, 386 insertions(+), 90 deletions(-) delete mode 100644 SwiftTerm/Sources/SwiftTerm/Mac/TerminalViewDelegate.swift 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