← cd /blog

Article

Debugging NSTextView Inside SwiftUI

·
buildstools

Wanted a markdown editor with line numbers inside a SwiftUI app. The kind of thing that sounds like an afternoon. It was not an afternoon.

The native macOS app for vaultctl has a workspace view: folder tree on the left, note list in the middle, editor on the right. The editor is an NSTextView wrapped in NSViewRepresentable because SwiftUI's TextEditor doesn't support line numbers, syntax highlighting, or anything resembling a real code editor.

The baseline that works

A plain NSTextView in SwiftUI is straightforward. NSTextView.scrollableTextView() gives you a scroll view with an embedded text view. Set your colours, fonts, and delegate, return it from makeNSView, done.

func makeNSView(context: Context) -> NSScrollView {
    let scrollView = NSTextView.scrollableTextView()
    guard let textView = scrollView.documentView as? NSTextView else { return scrollView }

    scrollView.frame = NSRect(x: 0, y: 0, width: 600, height: 400)
    textView.textContainerInset = NSSize(width: 12, height: 12)
    textView.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
    textView.backgroundColor = .black
    textView.textColor = .white
    textView.isRichText = false
    textView.delegate = context.coordinator

    textView.string = text
    return scrollView
}

That scrollView.frame line is doing more than it looks. NSTextView.scrollableTextView() creates everything at frame (0, 0, 0, 0). SwiftUI sets the real frame after makeNSView returns. Between those two moments, the text container calculates its width as textView.width - 2 * inset, which is 0 - 24 = -24. The layout manager caches that. Text disappears.

Setting an initial non-zero frame prevents the negative container width. The text renders. This took three builds to figure out, but fine. Solved.

Adding the ruler

NSScrollView has built-in support for ruler views. For line numbers, you create an NSRulerView subclass, set it as the vertical ruler, and the scroll view handles positioning. Standard AppKit pattern, documented, works in pure AppKit apps without issue.

let rulerView = LineNumberRulerView(textView: textView)
scrollView.verticalRulerView = rulerView
scrollView.hasVerticalRuler = true
scrollView.rulersVisible = true
scrollView.tile()

Text gone. Line numbers visible in the gutter, but the entire text area is blank. The string is there (textView.string has content), the layout manager just refuses to render it.

The zero-width problem

Here's what tile() does: it allocates space from the scroll view's width for the ruler. With ruleThickness = 40, the clip view becomes scrollView.width - 40. When the scroll view is at its real frame (say, 600px wide), that's 560px for the clip view. Fine.

But SwiftUI doesn't keep the initial frame. During its layout negotiation, it passes the view through a zero-width state. With the ruler present:

  1. scrollView.width = 0
  2. tile() allocates 40px for ruler: clipView.width = 0 - 40 = -40
  3. textView.width follows clip view: -40
  4. With widthTracksTextView = true: container.width = -40 - 24 = -64
  5. NSLayoutManager computes layout at width -64 and caches it
  6. SwiftUI eventually sets the real frame, but the layout manager never recomputes

The text is there. The layout is broken. Permanently.

Things that don't work

Deferred ruler installation. Observe frameDidChangeNotification, only install the ruler when scrollView.frame.width > 100. The text appears for a fraction of a second, then vanishes. SwiftUI resizes the view again after the ruler is installed, passing through zero. Same crash.

Manual container width management. Set widthTracksTextView = false, observe clip view frame changes, only update container.containerSize when width > 0:

@objc func clipViewFrameChanged(_ notification: Notification) {
    guard let clipView = notification.object as? NSClipView,
          let textView = clipView.documentView as? NSTextView,
          let container = textView.textContainer else { return }
    let width = clipView.bounds.width - textView.textContainerInset.width * 2
    if width > 0 {
        container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude)
    }
}

Doesn't matter. The initial tile() still creates a negative-width clip view. The guard only prevents further damage; the damage is already done.

NSViewControllerRepresentable with viewDidLayout(). Replace the representable with a view controller, use viewDidLayout() to set up the ruler after the first real layout pass. Same result. The view controller's view still passes through zero during SwiftUI's layout.

Setting the initial frame larger. Tried 600x400, 800x600, 1200x800. SwiftUI doesn't care what initial frame you set. It will resize through zero on its own schedule.

That's five approaches across fifteen builds. Each time: line numbers render perfectly in the gutter, text area completely blank.

The actual fix

Stop using NSRulerView.

The entire ruler mechanism (tile(), hasVerticalRuler, rulersVisible) is incompatible with SwiftUI's layout lifecycle. There is no timing, no deferral, no manual width management that prevents the zero-width transient state from breaking the layout.

Instead, subclass NSTextView and draw line numbers directly in the lineFragmentPadding area:

final class GutterTextView: NSTextView {
    override func drawBackground(in rect: NSRect) {
        super.drawBackground(in: rect)
        drawLineNumbers(in: rect)
    }
}

Set lineFragmentPadding = 40 on the text container. This creates 40px of space to the left of every line fragment, which is where the text cursor starts. No ruler, no tile(), no width subtraction. The padding is part of the text container's internal geometry, not the scroll view's.

textView.textContainer?.lineFragmentPadding = 40

Then in drawBackground(in:), enumerate visible line fragments from the layout manager and draw the numbers:

private func drawLineNumbers(in rect: NSRect) {
    guard let layoutManager = layoutManager,
          let textContainer = textContainer else { return }

    let text = string as NSString
    let gutterWidth = textContainer.lineFragmentPadding + textContainerInset.width
    let visibleRect = enclosingScrollView?.contentView.bounds ?? bounds
    let attrs: [NSAttributedString.Key: Any] = [
        .font: NSFont.monospacedSystemFont(ofSize: 10, weight: .regular),
        .foregroundColor: NSColor.secondaryLabelColor
    ]

    // Gutter background
    NSColor.darkGray.withAlphaComponent(0.3).setFill()
    NSRect(x: 0, y: visibleRect.origin.y,
           width: gutterWidth, height: visibleRect.height).fill()

    // Count lines before visible range
    let glyphRange = layoutManager.glyphRange(
        forBoundingRect: visibleRect, in: textContainer)
    let charRange = layoutManager.characterRange(
        forGlyphRange: glyphRange, actualGlyphRange: nil)
    var lineNumber = 1
    for i in 0..<charRange.location where i < text.length {
        if text.character(at: i) == 0x0A { lineNumber += 1 }
    }

    // Draw numbers for visible line fragments
    var glyphIndex = glyphRange.location
    while glyphIndex < NSMaxRange(glyphRange) {
        var lineRange = NSRange()
        layoutManager.lineFragmentRect(
            forGlyphAt: glyphIndex, effectiveRange: &lineRange)

        if glyphIndex == lineRange.location {
            let lineRect = layoutManager.lineFragmentRect(
                forGlyphAt: glyphIndex, effectiveRange: nil)
            let yOffset = lineRect.origin.y + textContainerInset.height

            let numStr = "\(lineNumber)" as NSString
            let size = numStr.size(withAttributes: attrs)
            numStr.draw(
                at: NSPoint(x: gutterWidth - size.width - 8, y: yOffset),
                withAttributes: attrs)

            // Count newlines in this fragment to advance lineNumber
            let fragChars = layoutManager.characterRange(
                forGlyphRange: lineRange, actualGlyphRange: nil)
            for i in fragChars.location..<NSMaxRange(fragChars)
                where i < text.length {
                if text.character(at: i) == 0x0A { lineNumber += 1 }
            }
        }
        glyphIndex = NSMaxRange(lineRange)
    }
}

The line numbers render inside the text view's own drawing, not in a separate ruler view. The scroll view never needs to subtract anything. The zero-width transient state doesn't break it because lineFragmentPadding is just an internal offset, not a frame calculation.

The scrolling gotcha

Replacing scrollView.documentView with the new subclass loses the scroll configuration that scrollableTextView() sets up. The text renders, line numbers appear, but you can't scroll. The text view doesn't grow vertically.

let gutterView = GutterTextView(
    frame: textView.frame, textContainer: textView.textContainer)

// Without these, scrolling is broken
gutterView.isVerticallyResizable = true
gutterView.isHorizontallyResizable = false
gutterView.autoresizingMask = [.width]
gutterView.textContainer?.widthTracksTextView = true
gutterView.textContainer?.heightTracksTextView = false
gutterView.maxSize = NSSize(
    width: CGFloat.greatestFiniteMagnitude,
    height: CGFloat.greatestFiniteMagnitude)

scrollView.documentView = gutterView

Five properties. All implicit. None documented as part of what scrollableTextView() configures. Found this by reading the AppKit headers after the text appeared but refused to scroll.

Syntax highlighting

With the base working, highlighting was straightforward. Set isRichText = true, apply NSAttributedString attributes to textStorage after text changes:

func textDidChange(_ notification: Notification) {
    guard let textView, !isHighlighting else { return }
    parent.text = textView.string
    isHighlighting = true
    MarkdownHighlighter.apply(to: textView)
    isHighlighting = false
}

The highlighter resets all attributes to defaults, then applies regex patterns for headers (larger font + accent colour), bold, italic, inline code (teal + surface background), wiki-links (accent colour), blockquotes (dimmed), and fenced code blocks. The isHighlighting guard prevents the delegate from re-entering when textStorage edits fire textDidChange again.

Headers get sized fonts. [[wiki-links]] get the accent colour. Inline code gets a teal tint with a subtle background. Frontmatter dims out. It looks like a code editor.

The summary

Three things that are not documented anywhere and cost about fifteen builds to discover:

  1. NSTextView.scrollableTextView() creates views at zero frame. SwiftUI will also pass through zero during layout. If textContainerInset is non-zero, the container width goes negative and the layout manager caches it permanently. Set an initial non-zero frame.

  2. NSRulerView with tile() is incompatible with SwiftUI's NSViewRepresentable. The ruler subtracts its thickness from the scroll view width during the zero-width transient state, producing a permanently negative clip view. No deferral or manual width management fixes this. Use lineFragmentPadding and drawBackground(in:) instead.

  3. Replacing scrollView.documentView after scrollableTextView() silently drops five scroll-related properties. Restore them manually or nothing scrolls.

The editor renders markdown with syntax highlighting, line numbers, and scrolling. It took two sessions and more SwiftUI/AppKit archaeology than anyone should need for a text view with numbers on the left.