← cd /blog

Article

Adding Line Numbers to an NSTextView in SwiftUI Without NSRulerView

·
buildstools

An NSRulerView on a SwiftUI-hosted NSTextView numbered the gutter and left the text blank. Five ways of keeping the ruler failed. The fix drops the ruler and draws the numbers inside the text view's own lineFragmentPadding.

The editor is the markdown pane of the native macOS app for vaultctl, next to a folder tree and a note list. It is an NSTextView wrapped in NSViewRepresentable, because SwiftUI's TextEditor has neither line numbers nor syntax highlighting.

Draw the gutter inside the text view

Subclass NSTextView, give the text container 40pt of lineFragmentPadding, draw the numbers in it from drawBackground(in:), and put back the scroll settings the replaced view had:

func makeNSView(context: Context) -> NSScrollView {
    let scrollView = NSTextView.scrollableTextView()
    guard let textView = scrollView.documentView as? NSTextView else { return scrollView }
    // ...
    // Use wide left inset as gutter for line numbers (no NSRulerView needed)
    textView.textContainerInset = NSSize(width: 12, height: 12)
    textView.textContainer?.lineFragmentPadding = 40  // left padding = gutter
    // ...
    // Replace the text view's documentView with our gutter-drawing subclass
    let gutterView = GutterTextView(frame: textView.frame, textContainer: textView.textContainer)
    // ...
    // Restore scroll properties that scrollableTextView() configures
    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)
    gutterView.minSize = NSSize(width: 0, height: 0)
    // ...
    scrollView.documentView = gutterView
    // ...
}

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

The drawLineNumbers(in:) body is under "lineFragmentPadding leaves room for the gutter" below.

Symptom: NSRulerView leaves the text area blank

NSScrollView supports ruler views, and an NSRulerView subclass is the standard AppKit way to number lines. The ruler version installed it like this:

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

The numbers rendered and the text area stayed blank.

SwiftUI hands the view width 0 first

What blanked the whole text area was never pinned down. The measurable part is the ruler pushing the text view 40pt left, which hides only 40pt of text.

tile() takes the ruler's thickness out of the text view's width. With ruleThickness = 40 on a 600pt scroll view, the clip view stays 600 wide and the text view gets 560.

SwiftUI's first setFrameSize on the hosted view is width 0, even when makeNSView set a 600x400 frame, and tile() still reserves the ruler's 40pt out of a width that has none, so the text view lands at x = -40. A probe on macOS 26, with the editor inside NSHostingView and HSplitView (the shape of the app's workspace), logged this:

  • At width 0: the clip view is 0 wide, the text view is 0 wide at x = -40, and the text container is -24 (zero minus two 12pt insets).
  • At 399.5: the clip view is 399.5, the text view 359.5 wide, still at x = -40.
  • Later resizes restore every width. The x = -40 stays.
  • In a bare NSWindow, each extra pass through zero moved the text view another 40pt left: -80, -120, -160, -200.
  • Without the ruler, the text view stays at x = 0 throughout.

Don't try to keep the ruler

Five ways of keeping the ruler failed. Each one numbered the gutter and left the text blank.

  1. The ruler as documented, installed in makeNSView and laid out with tile(). The text view ends up at x = -40 with the text area blank.
  2. Installing the ruler late. Observe frameDidChangeNotification and install it once scrollView.frame.width > 100. The text showed for a fraction of a second, then vanished. SwiftUI resized through zero again after the install.
  3. Guarding the container width. Set widthTracksTextView = false and update containerSize only when the computed width is above 0. The container width recovers by itself once the real frame arrives, so the guard has nothing to fix.
  4. NSViewControllerRepresentable with viewDidLayout(). Setting the ruler up after the first real layout pass changes nothing. The controller's view still passes through width 0.
  5. A bigger initial frame. The shipped editor kept a 600x400 frame. In the probe, SwiftUI's first resize still went to width 0 with it in place.

lineFragmentPadding leaves room for the gutter

lineFragmentPadding is space the text container leaves at both ends of every line fragment. Apple's docs say "The padding appears at the beginning and end of the line fragment rectangles." At 40, the first glyph sits at x = 40, so the left-hand 40pt is free for the gutter. The right margin loses 40pt too.

The gutter is the padding plus the inset, 52pt here. drawLineNumbers(in:) fills it, counts the newlines before the visible range, then draws a number at each visible line fragment. A self-contained version, with system colours in place of the app's theme:

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 numbers are part of the text view's own drawing. With no ruler, the text view kept x = 0 through SwiftUI's zero-width pass in the macOS 26 probe.

Restore the scroll settings after swapping the view

Replacing scrollView.documentView with the subclass drops part of what scrollableTextView() set up. Text and numbers render, but the view never grows taller and nothing scrolls. Apple's page for scrollableTextView() says nothing about what it configures. The seven lines under Restore scroll properties in makeNSView at the top put it back.

Note: The two tracking flags live on the text container, which the new view reuses, so they survive the swap. The probe's new view lost isVerticallyResizable, autoresizingMask, maxSize and minSize.

Highlight from textDidChange

With isRichText = true, the highlighter resets every attribute on textStorage to the defaults. Then it applies regex patterns for headers, bold, italic, inline code, wiki-links, blockquotes and fenced code blocks, among others:

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

The isHighlighting guard never triggers: attribute edits on textStorage don't fire textDidChange.