import AppKit /// A line-number gutter for an NSTextView, themed to match the editor. /// Highlights the line containing the insertion point. final class LineNumberRulerView: NSRulerView { private weak var textView: NSTextView? private var theme: EditorTheme = .system init(textView: NSTextView) { self.textView = textView super.init(scrollView: textView.enclosingScrollView, orientation: .verticalRuler) clientView = textView ruleThickness = 44 let center = NotificationCenter.default center.addObserver(self, selector: #selector(needsRedraw), name: NSText.didChangeNotification, object: textView) center.addObserver(self, selector: #selector(needsRedraw), name: NSTextView.didChangeSelectionNotification, object: textView) if let clip = textView.enclosingScrollView?.contentView { clip.postsBoundsChangedNotifications = true center.addObserver(self, selector: #selector(needsRedraw), name: NSView.boundsDidChangeNotification, object: clip) } } required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { NotificationCenter.default.removeObserver(self) } func apply(theme: EditorTheme) { self.theme = theme needsDisplay = true } /// Point the gutter at a different text view (used when the editor switches /// which board page it is showing). func retarget(to tv: NSTextView) { NotificationCenter.default.removeObserver(self) textView = tv clientView = tv let center = NotificationCenter.default center.addObserver(self, selector: #selector(needsRedraw), name: NSText.didChangeNotification, object: tv) center.addObserver(self, selector: #selector(needsRedraw), name: NSTextView.didChangeSelectionNotification, object: tv) if let clip = tv.enclosingScrollView?.contentView { clip.postsBoundsChangedNotifications = true center.addObserver(self, selector: #selector(needsRedraw), name: NSView.boundsDidChangeNotification, object: clip) } needsDisplay = true } @objc private func needsRedraw() { needsDisplay = true } /// Widen the gutter as the line count grows so 4- and 5-digit files fit. func updateThickness() { guard let lineCount = textView?.string.reduce(into: 1, { c, ch in if ch == "\n" { c += 1 } }) else { return } let digits = max(2, String(lineCount).count) let font = gutterFont let sample = String(repeating: "8", count: digits) let width = (sample as NSString).size(withAttributes: [.font: font]).width let thickness = ceil(width) + 16 if abs(thickness - ruleThickness) > 0.5 { ruleThickness = thickness } } private var gutterFont: NSFont { let base = (textView?.font?.pointSize ?? 13) - 2 return NSFont.monospacedDigitSystemFont(ofSize: max(9, base), weight: .regular) } override func drawHashMarksAndLabels(in rect: NSRect) { guard let textView, let layoutManager = textView.layoutManager, let container = textView.textContainer, let scrollView = scrollView else { return } theme.gutterBackground.setFill() bounds.fill() // Subtle separator line on the gutter's trailing edge. theme.gutterText.withAlphaComponent(0.25).setStroke() let sep = NSBezierPath() sep.move(to: NSPoint(x: bounds.maxX - 0.5, y: bounds.minY)) sep.line(to: NSPoint(x: bounds.maxX - 0.5, y: bounds.maxY)) sep.lineWidth = 1 sep.stroke() let content = scrollView.contentView let visibleRect = content.bounds let inset = textView.textContainerInset let nsString = textView.string as NSString let font = gutterFont let normalAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.gutterText] let activeAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.accent] // Which line holds the insertion point? let selectedLine = currentLineNumber(in: nsString, selection: textView.selectedRange()) let glyphRange = layoutManager.glyphRange(forBoundingRect: visibleRect, in: container) let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) var lineNumber = lineCount(in: nsString, upTo: charRange.location) var index = charRange.location while index <= NSMaxRange(charRange) { let lineRange = nsString.lineRange(for: NSRange(location: index, length: 0)) let glyphLineRange = layoutManager.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil) var lineRect = layoutManager.boundingRect(forGlyphRange: glyphLineRange, in: container) lineRect.origin.y += inset.height // Convert text Y into ruler Y. let y = lineRect.minY - visibleRect.minY let isActive = lineNumber == selectedLine if isActive { theme.currentLine.setFill() NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill() } let label = "\(lineNumber)" as NSString let attrs = isActive ? activeAttrs : normalAttrs let size = label.size(withAttributes: attrs) let drawY = y + (lineRect.height - size.height) / 2 label.draw(at: NSPoint(x: bounds.width - size.width - 8, y: drawY), withAttributes: attrs) if NSMaxRange(lineRange) <= index { break } index = NSMaxRange(lineRange) lineNumber += 1 if index > nsString.length { break } } } private func lineCount(in s: NSString, upTo location: Int) -> Int { var count = 1 var i = 0 let end = min(location, s.length) while i < end { if s.character(at: i) == 10 { count += 1 } i += 1 } return count } private func currentLineNumber(in s: NSString, selection: NSRange) -> Int { lineCount(in: s, upTo: selection.location) } }