Quellcode durchsuchen

Mac: Improve fonts metrics and line drawing (#55)

* Fix rows selection height

* Update after master merge

* Rework character width measurements

* Keep selection view around

* Terminal as an arg

* Fix line position

* struct TerminalOptions

* draw on context

* Update list count

* Rework draw lines

* Use CTLineDraw

* Use font maximum advacement to estimate terminal size

* Separate TerminalViewDelegate

* Adjust line for text (glyphs) only

* Fix scrolling

* Add bounds check

* Update TerminalViewDelegate.swift

* Undo whitespace change

* Preserve original style

* Use the same indentation I use, and spaces before ( and [

* Indentation

Co-authored-by: Miguel de Icaza <miguel@gnome.org>
Marcin Krzyzanowski vor 5 Jahren
Ursprung
Commit
22e7a52ad1

+ 1 - 0
.gitignore

@@ -3,3 +3,4 @@
 /Packages
 /*.xcodeproj
 xcuserdata/
+DerivedData

+ 2 - 0
SwiftTerm.xcodeproj/project.pbxproj

@@ -98,7 +98,9 @@
 				49BD1A5B224207B5005A2252 /* Products */,
 				49AE019F2410AB0A0051E902 /* Frameworks */,
 			);
+			indentWidth = 4;
 			sourceTree = "<group>";
+			tabWidth = 4;
 		};
 		49BD1A5B224207B5005A2252 /* Products */ = {
 			isa = PBXGroup;

+ 2 - 2
SwiftTerm/Sources/SwiftTerm/Buffer.swift

@@ -131,7 +131,7 @@ class Buffer {
         cols = terminal.cols
         rows = terminal.rows
         
-        let len = hasScrollback ? (terminal.options.scrollback ?? 0) + rows : rows
+        let len = hasScrollback ? (terminal.options.scrollback) + rows : rows
         _lines = CircularList<BufferLine> (maxLength: len)
         _lines.makeEmpty = makeEmptyLine
         setupTabStops ()
@@ -140,7 +140,7 @@ class Buffer {
     public func getCorrectBufferLength (_ rows: Int) -> Int
     {
         if hasScrollback {
-            let correct = rows + (terminal.options.scrollback ?? 0)
+            let correct = rows + (terminal.options.scrollback)
             return correct > Int32.max ? Int (Int32.max) : correct
         } else {
             return rows

+ 2 - 2
SwiftTerm/Sources/SwiftTerm/CircularList.swift

@@ -33,7 +33,7 @@ class CircularList<T> {
         }
     }
     
-    var _count: Int
+    private var _count: Int
     var maxLength: Int {
         didSet {
             if maxLength != oldValue {
@@ -82,7 +82,7 @@ class CircularList<T> {
         }
         set (newValue){
             array [getCyclicIndex(index)] = newValue
-        }
+      }
     }
     
     func push (_ value: T)

+ 47 - 41
SwiftTerm/Sources/SwiftTerm/Mac/MacSelectionView.swift

@@ -14,49 +14,54 @@ import CoreGraphics
  * This view renders the selection as a CAShapeMask
  */
 class SelectionView: NSView {
-    var terminalView: TerminalView!
-    var selection: SelectionService!
-    var maskLayer: CAShapeLayer!
-    var cellDim: CellDimensions
+
+    private let maskLayer: CAShapeLayer
+    private let terminalView: TerminalView
+
+    private var selection: SelectionService {
+      terminalView.selection
+    }
+    private var defaultLineHeight: CGFloat {
+      terminalView.defaultLineHeight
+    }
     
-    public init (terminalView: TerminalView, frame: CGRect)
+    public init(terminalView: TerminalView, frame: CGRect)
     {
         self.terminalView = terminalView
-        cellDim = terminalView.cellDim
-        selection = terminalView.selection
-        
-        super.init (frame: frame)
-        
+        self.maskLayer = CAShapeLayer()
+
+        super.init(frame: frame)
         wantsLayer = true
         
-        maskLayer = CAShapeLayer ()
         layer?.mask = maskLayer
         layer?.backgroundColor = NSColor.selectedTextBackgroundColor.withAlphaComponent(0.8).cgColor
     }
     
     public required init? (coder: NSCoder)
     {
-        abort ()
+        abort()
     }
 
-    func notifyScrolled ()
+    func notifyScrolled (source terminal: Terminal)
     {
-        update ()
+        update(with: terminal)
     }
     
-    func update ()
+    func update (with terminal: Terminal)
     {
-        updateMask ()
+        updateMask(with: terminal)
     }
     
-    func updateMask ()
+    func updateMask (with terminal: Terminal)
     {
         // remove the prior mask
         maskLayer.path = nil
         
         maskLayer.frame = bounds
-        let path = CGMutablePath()
-        let terminal = terminalView.terminal!
+        let path = CGMutablePath ()
+        guard let terminal = terminalView.terminal else {
+          return
+        }
         var start, end: Position
         
         if Position.compare (selection.start, selection.end) == .after {
@@ -80,8 +85,8 @@ class SelectionView: NSView {
             col = 0
         }
         
-        maskPartialRow (path: path, row: screenRowStart, colStart: start.col,  colEnd: col)
-        
+        maskPartialRow (path: path, row: screenRowStart, colStart: start.col, colEnd: col, terminal: terminal)
+
         if screenRowStart == screenRowEnd {
             // we're done, only one row to mask
             maskLayer.path = path
@@ -96,7 +101,7 @@ class SelectionView: NSView {
                 col = terminal.cols
             }
         }
-        maskPartialRow (path: path, row: screenRowEnd, colStart: col, colEnd: end.col)
+        maskPartialRow (path: path, row: screenRowEnd, colStart: col, colEnd: end.col, terminal: terminal)
         
         // now mask any full rows in between
         let fullRowCount = screenRowEnd - screenRowStart
@@ -111,40 +116,41 @@ class SelectionView: NSView {
         maskLayer.path = path
     }
     
-    func maskFullRows (path: CGMutablePath, rowStart: Int, rowCount: Int)
+    func maskFullRows(path: CGMutablePath, rowStart: Int, rowCount: Int)
     {
-        let cursorYOffset: CGFloat = 4
-        let startY = frame.height  - (CGFloat (rowStart + rowCount) * cellDim.height - cellDim.delta - cursorYOffset)
-        let pathRect = CGRect (x: 0, y: startY, width: frame.width, height: cellDim.height * CGFloat (rowCount))
+        let startY = frame.height  - (CGFloat (rowStart + rowCount) * defaultLineHeight)
+        let pathRect = CGRect (x: 0, y: startY, width: frame.width, height: defaultLineHeight * CGFloat (rowCount))
 
         path.addRect (pathRect)
     }
     
-    func maskPartialRow (path: CGMutablePath, row: Int, colStart: Int, colEnd: Int)
+    func maskPartialRow(path: CGMutablePath, row: Int, colStart: Int, colEnd: Int, terminal: Terminal)
     {
-        // -2 to get the top of the selection to fit over the top of the text properly
-        // and to align with the cursor
-        let cursorXPadding: CGFloat = 1
-        let cursorYOffset: CGFloat = 4
-        let startY = frame.height - cellDim.height - (CGFloat (row) * cellDim.height - cellDim.delta - cursorYOffset)
-        let startX = CGFloat (colStart) * cellDim.width
+        let startY = frame.height - (CGFloat(row + 1) * defaultLineHeight)
         var pathRect: CGRect
-        
-        if colStart == colEnd {
-            // basically the same as the cursor
-            pathRect = CGRect (x: startX - cursorXPadding, y: startY, width: cellDim.width + (2 * cursorXPadding), height: cellDim.height)
-        } else if (colStart < colEnd) {
+        let startOffset = self.terminalView.characterOffset (atRow: row + terminal.buffer.yDisp, col: colStart)
+        let endOffset = self.terminalView.characterOffset (atRow: row + terminal.buffer.yDisp, col: colEnd)
+
+        let width: CGFloat
+        if colEnd == terminal.cols {
+          width = frame.width - startOffset
+        } else {
+          width = endOffset - startOffset
+        }
+
+        if (colStart < colEnd) {
             // start before the beginning of the start column and end just before the start of the next column
-            pathRect =  CGRect (x: startX - cursorXPadding, y: startY, width: (CGFloat (colEnd - colStart) * cellDim.width) + (2 * cursorXPadding), height: cellDim.height)
+            pathRect = CGRect (x: startOffset, y: startY, width: width, height: defaultLineHeight)
         } else {
             // start before the beginning of the _end_ column and end just before the start of the _start_ column
             // note this creates a rect with negative width
-            pathRect = CGRect (x: startX + cursorXPadding, y: startY, width: (CGFloat(colEnd - colStart) * cellDim.width) - (2 * cursorXPadding), height: cellDim.height)
+            pathRect = CGRect (x: startOffset, y: startY, width: width, height: defaultLineHeight)
         }
         path.addRect(pathRect)
     }
     
-    override func hitTest(_ point: NSPoint) -> NSView? {
+    override func hitTest (_ point: NSPoint) -> NSView? 
+    {
         // we do not want to steal hits, let the terminal view take them
         return nil
     }

+ 250 - 193
SwiftTerm/Sources/SwiftTerm/Mac/MacTerminalView.swift

@@ -11,49 +11,13 @@ 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)
-    
-    /**
-     * The provided `data` needs to be sent to the application running inside the terminal
-     */
-    func send (source: TerminalView, data: ArraySlice<UInt8>)
-    
-    /**
-     * Invoked when the terminal has been scrolled and the new position is provided
-     */
-    func scrolled (source: TerminalView, position: Double)
-
-}
-
-//
-// Represents the cell dimensions used by AppKit to render
-//
-struct CellDimensions {
-    var width, height, delta: CGFloat
-}
-
 /**
  * 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.
  */
-public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserInterfaceValidations {
+public class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations {
 
     struct Font {
         let normal: NSFont
@@ -68,7 +32,8 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
     var attrStrBuffer: CircularList<NSAttributedString>!
     var accessibility: AccessibilityService = AccessibilityService()
     var search: SearchService!
-    var cellDim: CellDimensions!
+    /// Precalculated line height
+    var defaultLineHeight: CGFloat!
     var selectionView: SelectionView!
     var selection: SelectionService!
     var scroller: NSScroller!
@@ -87,13 +52,13 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
     public override init (frame: CGRect)
     {
         super.init (frame: frame)
-        setup (rect: frame)
+        setup(frame: frame)
     }
     
     public required init? (coder: NSCoder)
     {
         super.init (coder: coder)
-        setup (rect: self.bounds)
+        setup(frame: self.bounds)
     }
     
     public func getTerminal () -> Terminal
@@ -101,7 +66,7 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         return terminal
     }
     
-    func setup (rect: CGRect)
+    func setup(frame rect: CGRect)
     {
         let baseFont: NSFont
         if #available(OSX 10.15, *) {
@@ -115,27 +80,29 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
                     italic: NSFontManager.shared.convert(baseFont, toHaveTrait: [.italicFontMask]),
                     boldItalic: NSFontManager.shared.convert(baseFont, toHaveTrait: [.italicFontMask, .boldFontMask]))
       
-        _ = computeCellDimensions()
-        
-        let options = TerminalOptions ()
-        options.cols = Int (rect.width / cellDim.width)
-        options.rows = Int (rect.height / cellDim.height)
+        // Calculation assume that all glyphs in the font have the same advancement.
+        // Get the ascent + descent + leading from the font, already scaled for the font's size
+        self.defaultLineHeight = CTFontGetAscent (font.normal) + CTFontGetDescent (font.normal) + CTFontGetLeading (font.normal)
+        let options = TerminalOptions(cols: Int(rect.width / font.normal.maximumAdvancement.width),
+                                      rows: Int(rect.height / defaultLineHeight))
+
         terminal = Terminal(delegate: self, options: options)
-        fullBufferUpdate ()
-        
-        selection = SelectionService (terminal: terminal)
-        
-        caretView = CaretView (frame: CGRect (x: 0, y: cellDim.delta, width: cellDim.width, height: cellDim.height))
-        caretView.focused = false
+        fullBufferUpdate()
         
+        selection = SelectionService(terminal: terminal)
+
+        // Install selection vew
+        selectionView = SelectionView(terminalView: self, frame: .zero)
+        addSubview(selectionView)
+
+        // Install carret view
+        caretView = CaretView(frame: CGRect(origin: .zero, size: CGSize(width: font.normal.maximumAdvancement.width, height: defaultLineHeight)))
         addSubview(caretView)
-        
-        selectionView = SelectionView (terminalView: self, frame: CGRect (x: 0, y: 0, width: 0, height: 0))
 
         search = SearchService (terminal: terminal)
-        setupScroller (rect)
+        setupScroller(rect)
     }
-        
+
     /**
      * The delegate that the TerminalView uses to interact with its hosting
      */
@@ -186,17 +153,7 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
     }
     
     public var optionAsMetaKey: Bool = true
-    
-    func computeCellDimensions () -> CGRect
-    {
-      let line = CTLineCreateWithAttributedString (NSAttributedString (string: "W", attributes: [NSAttributedString.Key.font: font.normal]))
-        
-        let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds)
-        cellDim = CellDimensions(width: bounds.width, height: round (bounds.height), delta: bounds.minY)
-        
-        return bounds
-    }
-    
+
     public func bell(source: Terminal) {
         // TODO: do something with the bell
     }
@@ -209,29 +166,17 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         delegate?.send (source: self, data: data)
     }
     
-    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 ()
-    }
-    
+
     /**
      * Given the current set of columns and rows returns a frame that would host this control.
      */
     public func getOptimalFrameSize () -> NSRect
     {
-        return NSRect (x: 0, y: 0, width: cellDim.width*CGFloat(terminal.cols), height: cellDim.height*CGFloat (terminal.rows))
+        return NSRect (x: 0, y: 0, width: font.normal.maximumAdvancement.width * CGFloat(terminal.cols), height: defaultLineHeight * CGFloat(terminal.rows))
     }
     
-    public func scrolled(source: Terminal, yDisp: Int) {
-        selectionView.notifyScrolled ()
+    public func scrolled(source terminal: Terminal, yDisp: Int) {
+        selectionView.notifyScrolled(source: terminal)
         updateScroller()
         delegate?.scrolled(source: self, position: scrollPosition)
     }
@@ -392,8 +337,8 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         var bg = attribute.bg
         var fg = attribute.fg
         
-        if flags.contains(.inverse) {
-            swap(&bg, &fg)
+        if flags.contains (.inverse) {
+            swap (&bg, &fg)
             
             if fg == .defaultColor {
                 fg = .defaultInvertedColor
@@ -424,16 +369,17 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         var nsattr: [NSAttributedString.Key:Any] = [
             .font: font,
             .foregroundColor: fgColor,
-            .backgroundColor: mapColor (color: bg, isFg: false)
+            .fullBackgroundColor: mapColor(color: bg, isFg: false)
         ]
         if flags.contains (.underline) {
             nsattr [.underlineColor] = fgColor
             nsattr [.underlineStyle] = NSUnderlineStyle.single.rawValue
         }
         if flags.contains (.crossedOut) {
-            nsattr [.strikethroughStyle] = NSUnderlineStyle.single.rawValue
             nsattr [.strikethroughColor] = fgColor
+            nsattr [.strikethroughStyle] = NSUnderlineStyle.single.rawValue
         }
+
         attributes [attribute] = nsattr
         return nsattr
     }
@@ -456,14 +402,14 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
                 attr = ch.attribute
             } else {
                 if attr != ch.attribute {
-                    res.append(NSAttributedString (string: str, attributes: getAttributes (attr)))
+                    res.append (NSAttributedString (string: str, attributes: getAttributes(attr)))
                     str = ""
                     attr = ch.attribute
                 }
             }
-            str.append(ch.code == 0 ? " " : ch.getCharacter())
+            str.append(ch.code == 0 ? " " : ch.getCharacter ())
         }
-        res.append (NSAttributedString(string: str, attributes: getAttributes(attr)))
+        res.append (NSAttributedString (string: str, attributes: getAttributes (attr)))
         return res
     }
     
@@ -486,19 +432,20 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         for row in 0..<rows {
             attrStrBuffer [row] = buildAttributedString (line: terminal.buffer.lines [row], cols: cols, prefix: "")
         }
+        attrStrBuffer.count = rows
     }
     
     func makeEmptyLine (_ index: Int) -> NSAttributedString
     {
         let line = terminal.buffer.lines [index]
-        return buildAttributedString(line: line, cols: terminal.cols, prefix: "")
+        return buildAttributedString (line: line, cols: terminal.cols, prefix: "")
     }
     
     func updateDisplay (notifyAccessibility: Bool)
     {
         updateCursorPosition ()
 
-         guard let (rowStart, rowEnd) = terminal.getUpdateRange() else {
+         guard let (rowStart, rowEnd) = terminal.getUpdateRange () else {
             return
         }
         
@@ -514,74 +461,171 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         }
         
         //print ("Dirty is \(rowStart) to \(rowEnd)")
-        // BROKWN:
-        let baseLine = frame.height - cellDim.delta
+        // BROKEN:
+        let baseLine = frame.height
         let region = CGRect (x: 0,
-                             y: baseLine - (cellDim.height + CGFloat (rowEnd) * cellDim.height) + 2*cellDim.delta,
+                             y: baseLine - (defaultLineHeight + CGFloat(rowEnd) * defaultLineHeight),
                              width: frame.width,
-                             height: CGFloat ((rowEnd-rowStart+1))*cellDim.height + CGFloat (abs (cellDim.delta * 2)))
+                             height: CGFloat(rowEnd-rowStart + 1) * defaultLineHeight)
         
         //print ("Region: \(region)")
-        setNeedsDisplay (region)
+        setNeedsDisplay(region)
         pendingDisplay = false
         debug?.update()
         
         if (notifyAccessibility) {
             accessibility.invalidate ()
-            NSAccessibility.post(element: self, notification: .valueChanged)
-            NSAccessibility.post(element: self, notification: .selectedTextChanged)
+            NSAccessibility.post (element: self, notification: .valueChanged)
+            NSAccessibility.post (element: self, notification: .selectedTextChanged)
         }
     }
+
+    private func ctline(forRow row: Int) -> 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)
+    }
     
     // TODO: Clip here
-    override public func draw(_ dirtyRect: NSRect) {
-        defBgColor.set ()
-        bounds.fill()
-    
-        //print ("Dirty rect is: \(dirtyRect)")
-        defFgColor.set ()
+    override public func draw (_ dirtyRect: NSRect) {
+        // it doesn't matter. Our attributed string has color set anyway
+        defFgColor.set()
+
         guard let context = NSGraphicsContext.current?.cgContext else {
             return
         }
+
+        // draw background
         context.saveGState()
+        context.setFillColor(defBgColor.cgColor)
+        context.fill(dirtyRect)
+        context.restoreGState()
+
+        context.saveGState()
+
+        // lines to draw
+        // TODO: for the performance reasons, it's better to create CTLine when attrStrBuffer is updated
+        let lines: [CTLine] = attrStrBuffer.array[terminal.buffer.yDisp...].compactMap({
+            guard let value = $0 else {
+                return nil
+            }
+            return CTLineCreateWithAttributedString(value)
+        })
+
+        // draw lines
+        var prevY: CGFloat = 0
+        for line in lines {
+            var lineAscent: CGFloat = 0
+            var lineDescent: CGFloat = 0
+            var lineLeading: CGFloat = 0
+            _ = CTLineGetTypographicBounds (line, &lineAscent, &lineDescent, &lineLeading)
+            let currentLineHeight = lineAscent + lineDescent + lineLeading
+            let lineOrigin = CGPoint (x: 0, y: frame.height - (currentLineHeight + prevY))
+
+            // Draw line manually, so we can run custom routine for background color
+            for glyphRun in CTLineGetGlyphRuns (line) as? [CTRun] ?? [] {
+                let runAttributes = CTRunGetAttributes(glyphRun) as? [NSAttributedString.Key: Any] ?? [:]
+
+                var runAscent: CGFloat = 0
+                var runDescent: CGFloat = 0
+                var runLeading: CGFloat = 0
+                let runWidth = CTRunGetTypographicBounds (glyphRun, CFRange (), &runAscent, &runDescent, &runLeading)
 
-        let maxRow = terminal.rows
-        let yDisp = terminal.buffer.yDisp
-        let baseLine = frame.height - cellDim.delta
-        for row in 0..<maxRow {
-            context.textPosition = CGPoint (x: 0, y: baseLine - (cellDim.height + CGFloat (row) * cellDim.height))
-            let attrLine = attrStrBuffer[row + yDisp]
-            // var dbg = NSAttributedString (string: "\(row)", attributes: getAttributes(CharData.defaultAttr))
-            let ctline = CTLineCreateWithAttributedString(attrLine)
-            CTLineDraw(ctline, context)
-            context.drawPath(using: .fillStroke)
+                // Default to font.normal
+                var runFont = font.normal
+                if runAttributes.keys.contains(.font) {
+                    runFont = runAttributes[.font] as! NSFont
+                }
+
+                // Get glyphs positions
+                var glyphsPositions = [CGPoint](repeating: .zero, count: CTRunGetGlyphCount (glyphRun))
+                CTRunGetPositions(glyphRun, CFRange(), &glyphsPositions)
+
+                // Draw background.
+                // Background color fill the entire height of the line.
+                if runAttributes.keys.contains(.fullBackgroundColor) {
+                    let backgroundColor = runAttributes[.fullBackgroundColor] as! NSColor
+
+                    var transform = CGAffineTransform (translationX: glyphsPositions[0].x, y: 0)
+                    let path = CGPath (rect: CGRect (origin: lineOrigin, size: CGSize (width: CGFloat (runWidth), height: currentLineHeight)), transform: &transform)
+
+                    context.saveGState ()
+
+                    context.setShouldAntialias (false)
+                    context.setLineCap (.square)
+                    context.setLineWidth(0)
+                    context.setFillColor(backgroundColor.cgColor)
+                    context.setStrokeColor(backgroundColor.cgColor)
+                    context.addPath(path)
+                    context.drawPath(using: .fill)
+
+                    context.restoreGState()
+                }
+
+                // Draw glyphs
+                // Not really needed, use CTLineDraw instead
+                #if false
+                // Adjust positions for text
+                let baseLineAdj = runFont.descender + runFont.leading
+                glyphsPositions = glyphsPositions.map({ CGPoint(x: $0.x, y: lineOrigin.y + baseLineAdj) })
+
+                // Set foreground color
+                if runAttributes.keys.contains(.foregroundColor) {
+                    let color = runAttributes[.foregroundColor] as! NSColor
+                    context.setFillColor(color.cgColor)
+                }
+
+                if runAttributes.keys.contains(.underlineColor) {
+                    let color = runAttributes[.underlineColor] as! NSColor
+                    context.setFillColor(color.cgColor)
+                }
+
+                if runAttributes.keys.contains(.strikethroughColor) {
+                    let color = runAttributes[.strikethroughColor] as! NSColor
+                    context.setFillColor(color.cgColor)
+                }
+
+                var glyphs = [CGGlyph](repeating: .zero, count: CTRunGetGlyphCount(glyphRun))
+                CTRunGetGlyphs(glyphRun, CFRange(), &glyphs)
+
+                // TODO: disable antialiasing for non-letters
+                //
+                for (i, glyph) in glyphs.enumerated() {
+                    var transform = CGAffineTransform(translationX: glyphsPositions[i].x, y: lineOrigin.y - baseLineAdj)
+                    if let path = CTFontCreatePathForGlyph(runFont, glyph, &transform) {
+                        context.addPath(path)
+                        context.drawPath(using: .fill)
+                    }
+                }
+                #endif
+            }
+
+            // The code above is CTLineDraw() in disguise
+            let baseLineAdj = font.normal.descender + font.normal.leading
+            context.textPosition = CGPoint (x: 0, y: lineOrigin.y - baseLineAdj)
+            CTLineDraw (line, context)
+
+            prevY += currentLineHeight
         }
-        context.restoreGState()
+
+        context.restoreGState ()
     }
     
     func updateCursorPosition ()
     {
-        let pos = getCaretPos (terminal.buffer.x, terminal.buffer.y)
-        caretView.frame = CGRect (
-            // -1 to pad outside the character a little bit
-            x: pos.x - 1,
-            // -2 to get the top of the selection to fit over the top of the text properly
-            // and to align with the cursor
-            y: pos.y + cellDim.delta,
-            //Frame.Height - cellDim.height - ((terminal.Buffer.Y + terminal.Buffer.YBase - terminal.Buffer.YDisp) * cellDim.height - cellDim.delta - 2),
-            // +2 to pad outside the character a little bit on the other side
-            width: cellDim.width + 2,
-            height: cellDim.height + 0)
-    }
-
-    func getCaretPos(_ x: Int, _ y: Int) -> (x: CGFloat, y: CGFloat)
+        caretView.frame.origin = getCaretPos (terminal.buffer.x, terminal.buffer.y)
+    }
+
+    func getCaretPos(_ col: Int, _ row: Int) -> CGPoint
     {
-        let baseLine = frame.height - cellDim.delta
-        let ip = (cellDim.height + CGFloat (y) * cellDim.height)
-        let x_ = CGFloat (x) * cellDim.width
-        
-        let y_ = baseLine - ip
-        return (x_, y_)
+        let x = self.characterOffset (atRow: row, col: col)
+        let y = frame.height - (defaultLineHeight + (CGFloat (row) * defaultLineHeight))
+        return CGPoint (x: x, y: y)
     }
 
     // Does not use a default argument and merge, because it is called back
@@ -644,8 +688,8 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         set(newValue) {
             super.frame = newValue
 
-            let newRows = Int (newValue.height / cellDim.height)
-            let newCols = Int (newValue.width / cellDim.width)
+            let newRows = Int (newValue.height / defaultLineHeight)
+            let newCols = Int (newValue.width / font.normal.maximumAdvancement.width)
 
             if newCols != terminal.cols || newRows != terminal.rows {
                 terminal.resize (cols: newCols, rows: newRows)
@@ -655,7 +699,7 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
             // make the selection view the entire visible portion of the view
             // we will mask the selected text that is visible to the user
             selectionView.frame = bounds
-            scroller.frame = getScrollerFrame(frame)
+            scroller.frame = getScrollerFrame (frame)
             updateCursorPosition ()
             
             accessibility.invalidate ()
@@ -672,7 +716,7 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
     public func resize (cols: Int, rows: Int)
     {
         terminal.resize (cols: cols, rows: rows)
-        sizeChanged(source: terminal)
+        sizeChanged (source: terminal)
         terminal.reset()
     }
     
@@ -680,9 +724,10 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
      * Sends the specified slice of byte arrays to the program running under the terminal emulator
      * - Parameter data: the slice of an array to send to the client
      */
-    public func send(data: ArraySlice<UInt8>) {
+    public func send(data: ArraySlice<UInt8>) 
+    {
         ensureCaretIsVisible ()
-        delegate?.send(source: self, data: data)
+        delegate?.send (source: self, data: data)
     }
     
     /**
@@ -702,10 +747,10 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         send (data: (bytes)[...])
     }
 
-    var _hasFocus = false
+    private var _hasFocus = false
     public var hasFocus : Bool {
         get { _hasFocus }
-        set(newValue) {
+        set {
             _hasFocus = newValue
             caretView.focused = newValue
         }
@@ -723,13 +768,13 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
             // do the display update
             updateDisplay (notifyAccessibility: notifyAccessibility)
             
-            selectionView.notifyScrolled ()
+            selectionView.notifyScrolled(source: terminal)
             delegate?.scrolled (source: self, position: scrollPosition)
             updateScroller()
         }
     }
     
-    func ensureCaretIsVisible ()
+    private func ensureCaretIsVisible ()
     {
         let realCaret = terminal.buffer.y + terminal.buffer.yBase
         let viewportEnd = terminal.buffer.yDisp + terminal.rows
@@ -985,7 +1030,7 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
             return r
         }
         
-        return NSRect ()
+        return .zero
     }
     
     // NSTextInputClient protocol implementation
@@ -1024,14 +1069,7 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
     }
     
     public func selectionChanged(source: Terminal) {
-        if selection.active {
-            if  selectionView.superview == nil {
-                addSubview(selectionView)
-            }
-        } else {
-            selectionView.removeFromSuperview()
-        }
-        selectionView.update()
+        selectionView.update(with: source)
     }
 
     func cut (sender: Any?) {}
@@ -1078,21 +1116,24 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
     func calculateMouseHit (with event: NSEvent) -> Position
     {
         let point = convert(event.locationInWindow, from: nil)
-        let col = Int (point.x / cellDim.width)
-        let row = Int ((frame.height-point.y) / cellDim.height)
-        return Position (col: col, row: row)
+        let row = Int((frame.height - point.y) / defaultLineHeight)
+        if row < 0 {
+            return Position(col: 0, row: 0)
+        }
+        let col = CTLineGetStringIndexForPosition(self.ctline(forRow: row), point)
+        return Position(col: col, row: row)
     }
     
-    func sharedMouseEvent (with event: NSEvent)
+    private func sharedMouseEvent (with event: NSEvent)
     {
         let hit = calculateMouseHit(with: event)
         let buttonFlags = encodeMouseEvent(with: event)
         terminal.sendEvent(buttonFlags: buttonFlags, x: hit.col, y: hit.row)
     }
     
-    var autoScrollDelta = 0
+    private var autoScrollDelta = 0
     // Callback from when the mouseDown autoscrolling timer goes off
-    func scrollingTimerElapsed (source: Timer)
+    private func scrollingTimerElapsed (source: Timer)
     {
         if autoScrollDelta == 0 {
             return
@@ -1113,28 +1154,25 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         }
 
         let hit = calculateMouseHit(with: event)
-#if DEBUG
-        // print ("Down at col=\(hit.col) row=\(hit.row) count=\(event.clickCount) selection.active=\(selection.active) didSelectionDrag=\(didSelectionDrag) ")
-        #endif
 
-      switch event.clickCount {
-        case 1:
-          if selection.active == true {
-            if event.modifierFlags.contains(.shift) {
-              selection.shiftExtend(row: hit.row, col: hit.col)
-            } else {
-              selection.active = false
+        switch event.clickCount {
+          case 1:
+            if selection.active == true {
+              if event.modifierFlags.contains(.shift) {
+                selection.shiftExtend(row: hit.row, col: hit.col)
+              } else {
+                selection.active = false
+              }
             }
-          }
-        case 2:
-          selection.selectWordOrExpression(at: Position(col: hit.col, row: hit.row + terminal.buffer.yDisp), in: terminal.buffer)
-        default:
-          // 3 and higher
-          selection.select(row: hit.row + terminal.buffer.yDisp)
-      }
+          case 2:
+            selection.selectWordOrExpression(at: Position(col: hit.col, row: hit.row + terminal.buffer.yDisp), in: terminal.buffer)
+          default:
+            // 3 and higher
+            selection.select(row: hit.row + terminal.buffer.yDisp)
+        }
     }
 
-    var didSelectionDrag: Bool = false
+    private var didSelectionDrag: Bool = false
     public override func mouseUp(with event: NSEvent) {
         super.mouseUp(with: event)
 
@@ -1160,12 +1198,11 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
             
             return
         }
+
         if terminal.mouseMode != .off {
             return
         }
-        #if DEBUG
-        // print ("Drag at col=\(hit.col) row=\(hit.row) active=\(selection.active)")
-        #endif
+
         if selection.active {
             selection.dragExtend(row: hit.row, col: hit.col)
         } else {
@@ -1204,7 +1241,7 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
         }
     }
     
-    func calcScrollingVelocity (delta: Int) -> Int
+    private func calcScrollingVelocity (delta: Int) -> Int
     {
         if delta > 9 {
             return max (terminal.rows, 20)
@@ -1226,16 +1263,31 @@ public class TerminalView: NSView, TerminalDelegate, NSTextInputClient, NSUserIn
     public func isProcessTrusted() -> Bool {
         true
     }
-    
-    // Terminal.Delegate method implementation
-    public func setTerminalIconTitle(source: Terminal, title: String) {
-        //
-    }
-    
-    // Terminal.Delegate method implementation
-    public func windowCommand(source: Terminal, command: Terminal.WindowManipulationCommand) -> [UInt8]? {
-        return nil
-    }
+}
+
+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
+  }
+
 }
 
 private extension NSColor {
@@ -1251,3 +1303,8 @@ private extension NSColor {
 }
 
 #endif
+
+
+extension NSAttributedString.Key {
+    static let fullBackgroundColor: NSAttributedString.Key = .init("SwiftTerm_fullBackgroundColor") // NSColor, default nil: no background
+}

+ 33 - 0
SwiftTerm/Sources/SwiftTerm/Mac/TerminalViewDelegate.swift

@@ -0,0 +1,33 @@
+//
+//  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<UInt8>)
+
+  /**
+   * Invoked when the terminal has been scrolled and the new position is provided
+   */
+  func scrolled (source: TerminalView, position: Double)
+}

+ 22 - 10
SwiftTerm/Sources/SwiftTerm/TerminalOptions.swift

@@ -17,14 +17,26 @@ public enum CursorStyle {
     case steadyBar
 }
 
-public class TerminalOptions {
-    public var cols: Int = 80
-    public var rows: Int = 25
-    public var convertEol: Bool = true
-    public var cursorBlink: Bool = false
-    public var termName: String = "xterm"
-    public var cursorStyle = CursorStyle.blinkBlock
-    public var screenReaderMode: Bool = false
-    public var scrollback: Int? = 500
-    public var tabStopWidth: Int? = 8
+public struct TerminalOptions {
+    public var cols: Int
+    public var rows: Int
+    public var convertEol: Bool
+    public var cursorBlink: Bool
+    public var termName: String
+    public var cursorStyle: CursorStyle
+    public var screenReaderMode: Bool
+    public var scrollback: Int
+    public var tabStopWidth: Int
+
+    public init(cols: Int = 80, rows: Int = 25, convertEol: Bool = true, cursorBlink: Bool = false, termName: String = "xterm", cursorStyle: CursorStyle = CursorStyle.blinkBlock, screenReaderMode: Bool = false, scrollback: Int = 500, tabStopWidth: Int = 8) {
+        self.cols = cols
+        self.rows = rows
+        self.convertEol = convertEol
+        self.cursorBlink = cursorBlink
+        self.termName = termName
+        self.cursorStyle = cursorStyle
+        self.screenReaderMode = screenReaderMode
+        self.scrollback = scrollback
+        self.tabStopWidth = tabStopWidth
+    }
 }