Setting the NSTextView Line Height in a Beautiful Way Jul 15th, 2017

In the original post about a cheap way to set the line height in a text view to, say, 150%, the result kind of worked but didn’t look that cool. One issue is that the extra line spacing was exclusively added at the bottom.

With the following solution, you’ll get a proper line height with tastefully aligned insertion point and baseline and all.

This is what you really want when you increase the line height

The TextKit/Cocoa Text thing we are going to change is the lineFragmentRect . TextKit is immensely powerful, but also complex and not easy to start out working with. To increase any given lineFragmentRect , implement NSLayoutManagerDelegate like this:

class ViewController : NSViewController , NSLayoutManagerDelegate { let lineHeightMultiple : CGFloat = 1.6 let font : NSFont = NSFont . systemFont ( ofSize : NSFont . systemFontDefaultSize ()) public func layoutManager ( _ layoutManager : NSLayoutManager , shouldSetLineFragmentRect lineFragmentRect : UnsafeMutablePointer < NSRect > , lineFragmentUsedRect : UnsafeMutablePointer < NSRect > , baselineOffset : UnsafeMutablePointer < CGFloat > , in textContainer : NSTextContainer , forGlyphRange glyphRange : NSRange ) -> Bool { let fontLineHeight = layoutManager . defaultLineHeight ( for : font ) let lineHeight = fontLineHeight * lineHeightMultiple let baselineNudge = ( lineHeight - fontLineHeight ) // The following factor is a result of experimentation: * 0.6 var rect = lineFragmentRect . pointee rect . size . height = lineHeight var usedRect = lineFragmentUsedRect . pointee usedRect . size . height = max ( lineHeight , usedRect . size . height ) // keep emoji sizes lineFragmentRect . pointee = rect lineFragmentUsedRect . pointee = usedRect baselineOffset . pointee = baselineOffset . pointee + baselineNudge return true } }

Note that this will increase the line height for all lines – except the last trailing newline or the height of the blinking insertion point in an empty document.

When you have an empty text or add a trailing newline character to your text, the insertion point is actually outside the very text container you know and love. NSLayoutManager has a extraLineFragmentRectContainer that takes care of the extraLineFragmentRect – which is used for the last (or only) empty line in a text. You need to increase that to a similar value.

If you have trouble wrapping your head around this concept, think about it this way: a newline character is not actually a glyph. It is not drawn. On top of that, a "

" does belong to the line it is put on, but then you do not really have a line following after that until you type.

The following string:

The first line

and the second,

but after this, there's no 4th.



… will be treated by text editors like:

The first line↩︎ and the second↩︎ but after this, there's no 4th↩︎

… where the last line break is a character of the 3rd line, but renders as:

The first line and the second, but after this, there's no 4th.

These 3 lines of text are supposed to illustrate that the trailing newline character is a character of the last line with text in it; if you are in a text editor and put the insertion point after the last

-newline, you’ll be taken to a 4th line of text that doesn’t exist in the document. It’s merely a user experience thing.

So the layout manager cheats. When there’s a trailing newline character (or empty text), it appends the extraLineFragmentRect . In that rect, your insertion point blinks.

To change the height of the extraLineFragmentRect to suit a larger line height multiple setting, there are two places to intervene:

NSLayoutManager.setExtraLineFragmentRect(_:,usedRect:,textContainer:) itself can be changed to call super with a different line height. Subclassing NSTypesetter and suggesting a larger rect in every occasion you would call NSLayoutManager.setExtraLineFragmentRect .

I have no clue about NSTypesetter , so the next best thing I can control (and thus suggest you do as well) is overriding setExtraLineFragmentRect .

class LayoutManager : NSLayoutManager { var lineHeightMultiple : CGFloat = 1.6 private var font : NSFont { return self . firstTextView ? . font ?? NSFont . systemFont ( ofSize : NSFont . systemFontSize ()) } private var lineHeight : CGFloat { let fontLineHeight = self . defaultLineHeight ( for : font ) let lineHeight = fontLineHeight * lineHeightMultiple return lineHeight } // Takes care only of the last empty newline in the text backing // store, or totally empty text views. override func setExtraLineFragmentRect ( _ fragmentRect : NSRect , usedRect : NSRect , textContainer container : NSTextContainer ) { // This is only called when editing, and re-computing the // `lineHeight` isn't that expensive, so I do no caching. let lineHeight = self . lineHeight var fragmentRect = fragmentRect fragmentRect . size . height = lineHeight var usedRect = usedRect usedRect . size . height = lineHeight super . setExtraLineFragmentRect ( fragmentRect , usedRect : usedRect , textContainer : container ) } }

So you end up with a NSLayoutManagerDelegate and a NSLayoutManager and essentially put similar calculations in two different places. You could argue that since we’re working with a NSLayoutManager subclass now anyway, we could override setLineFragmentRect (note the missing “Extra”), too, and have both settings in one place.

I like to keep the dedicated delegate methods alive as long as I can, though, and find the baseline offset setting very convenient for our purposes.

And that’s about it!

If you never dipped your toe into the intricacies of TextKit, I guess you fell like I did: not happy about the many things you have to know for such a simple effect. “If I have to perform this kind of calculation and conform to that kind of complicated delegate methods to increase the line height,” you might ask, “what will I have to go through to implement really fancy features?” – And I really sympathize with that irritation. There’s so many intricacies to know! After 2 weeks of fighting with TextKit, I now have a better overview but still fail to guess which component does what; we’ll see how deep I have to dive and what I’ll find out in the upcoming weeks and months.

Receive new .