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:
scrollView.width = 0tile()allocates 40px for ruler:clipView.width = 0 - 40 = -40textView.widthfollows clip view:-40- With
widthTracksTextView = true:container.width = -40 - 24 = -64 NSLayoutManagercomputes layout at width-64and caches it- 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:
-
NSTextView.scrollableTextView()creates views at zero frame. SwiftUI will also pass through zero during layout. IftextContainerInsetis non-zero, the container width goes negative and the layout manager caches it permanently. Set an initial non-zero frame. -
NSRulerViewwithtile()is incompatible with SwiftUI'sNSViewRepresentable. 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. UselineFragmentPaddinganddrawBackground(in:)instead. -
Replacing
scrollView.documentViewafterscrollableTextView()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.