Vitaliy Vashchenko Vitaliy Vashchenko - 7 months ago 139
Swift Question

NSTextView selection highlights all characters even paragraph indents

Can't find any clue how to manage this.

By default, NSTextView selection highlights the whole size of its text container. It ignores line spacing, head or tail indents etc. But in Pages app selection doesn't highlight those ancillary parts, it highlight characters ONLY. And it highlights all the height of the line even if text container's height is smaller (paragraph spacing before and after).

I want to implement that behavior but can't understand where to begin. I've searched here, I've searched Apple docs, I've tried sample projects. Nothing.

Maybe someone can guide me in the right direction? Thanks!

Answer

I found that hamstergene's answer isn't correct. In fact, NSTextView highlights its text container lines all over their bounds.

So, if you use paragraph head indents then there will be empty space leading the text highlighted. And if you select EOL character then the tail of the text container will be highlighted.

My solution was to nullify head and tail indents of the paragraph style (I cache them in the private variable to and put them back when my text storage is accessed for printing) and simply adjust frame of the text container line via overrided lineFragmentRectForProposedRect: atIndex: writingDirection: remainingRect method of my NSTextContainer subclass.

public class AdjustableTextContainer: NSTextContainer {
public override var simpleRectangularTextContainer: Bool {
    return false
}

public override func lineFragmentRectForProposedRect(proposedRect: NSRect, atIndex characterIndex: Int, writingDirection baseWritingDirection: NSWritingDirection, remainingRect: UnsafeMutablePointer<NSRect>) -> NSRect {

    var rect = super.lineFragmentRectForProposedRect(proposedRect, atIndex: characterIndex, writingDirection: baseWritingDirection, remainingRect: remainingRect)

    let range = NSMakeRange(characterIndex, 0)
    let textStorage = layoutManager!.textStorage as! SectionedTextStorage

    // get the head indent (offset of the text container line)
    var attributes = // get them from wherever you store them
    let style = attributes[NSParagraphStyleAttributeName]!
    let indent = style.headIndent
    rect.origin.x = indent

    // calculate line width
    let lineRanges = textStorage.lineRanges()
    if lineRanges.count > 0 {
        if let lineNumber = lineRanges.indexOf( {$0.contains(NSMakeRange(characterIndex, 0))} ) {
            let lineRange = lineRanges[lineNumber]
            var substring = (textStorage.string as NSString).substringWithRange(lineRange)

            // get EOL character width for proper width calculation
            var eolWidth: CGFloat = 0.0
            if let lastChar = substring.characters.last where lastChar == "\n" {
                let replaceRange = substring.endIndex.predecessor() ..< substring.endIndex
                substring.replaceRange(replaceRange, with: "")

                // empirically found EOL character width offset to prevent the selection drawing glitche
                let eolRawWidth = NSAttributedString(string: "¶", attributes: attributes).size().width
                eolWidth = CGFloat(eolRawWidth - 0.1)
            }

            // get substring width
            let attrString = NSAttributedString(string: substring, attributes: attributes)
            var width = attrString.size().width + eolWidth

            // adjust width min value
            width = width > 0 ? width : 1

            // adjust width max value
            if width > style.tailIndent - style.headIndent {
                width = style.tailIndent - style.headIndent
            }

            rect.size.width = width
        }
    }
    return rect
}

Here's the lineRanges method of my NSTextStorage subclass:

public func lineRanges() -> [NSRange] {
    var lineCount = [NSRange]()
    var index =  0

    repeat {
        let resultRange = (self.string as NSString).lineRangeForRange(NSMakeRange(index, 0))
        index = NSMaxRange(resultRange)
        lineCount.append(resultRange)
    }
    while index < self.string.characters.count

    return lineCount
}

It worked like a charm.

P.S.: end-of-line character I show in drawGlyphsForGlyphRange: atPoint method of my NSLayoutManager subclass. Just need to make sure this character is selected before showing it. Something like that:

public class LayoutManager: NSLayoutManager {
public override func drawGlyphsForGlyphRange(glyphsToShow: NSRange, atPoint origin: NSPoint) {
    // show end of line character for selected text
    if let textView = firstTextView {
        for value in textView.selectedRanges {
            let range = value.rangeValue
            if range.contains(glyphsToShow) && range.length > 0 {
                let lengthToRedraw = NSMaxRange(range)
                for index in range.location ..< lengthToRedraw {
                    if (textStorage!.string as NSString).characterAtIndex(index) == 0x0A {
                        var pointToDrawAt = self.locationForGlyphAtIndex(index)
                        let glyphFragment = self.lineFragmentRectForGlyphAtIndex(index, effectiveRange: nil)
                        pointToDrawAt.x += glyphFragment.origin.x
                        pointToDrawAt.y = glyphFragment.origin.y
                        let endOfLine = "¶" as NSString
                        var attributes = self.firstTextView!.typingAttributes
                        attributes[NSForegroundColorAttributeName] = Color.darkGrayColor()
                        endOfLine.drawAtPoint(pointToDrawAt, withAttributes:attributes)
                    }
                }
            }
        }
    }
    super.drawGlyphsForGlyphRange(glyphsToShow, atPoint: origin)
}
}