#if os(macOS) import AppKit /// MARK: - Types private struct RulerObserverToken: @unchecked Sendable { let raw: NSObjectProtocol } final class LineNumberRulerView: NSRulerView { weak var textView: NSTextView? private let font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) private let inset: CGFloat = 6 private var observers: [RulerObserverToken] = [] private var cachedDigitCount: Int = 2 private var cachedLineStarts: [Int] = [0] private var cachedTextLength: Int = 0 private var needsLineCacheRebuild: Bool = true private var lineNumberColor: NSColor { NSColor.tertiaryLabelColor.withAlphaComponent(0.92) } init(textView: NSTextView) { self.textView = textView super.init(scrollView: textView.enclosingScrollView, orientation: .verticalRuler) self.clientView = textView self.ruleThickness = 48 installObservers(textView: textView) updateRuleThicknessIfNeeded() } required init(coder: NSCoder) { super.init(coder: coder) self.ruleThickness = 48 } deinit { for observer in observers { NotificationCenter.default.removeObserver(observer.raw) } } override var isOpaque: Bool { true } override func draw(_ dirtyRect: NSRect) { rebuildLineCacheIfNeeded() let bg: NSColor = { guard let tv = textView else { return .windowBackgroundColor } let color = tv.backgroundColor if color.alphaComponent >= 0.99 { return color } if let windowColor = tv.window?.backgroundColor { return windowColor } return .windowBackgroundColor }() bg.setFill() bounds.fill() NSColor.separatorColor.withAlphaComponent(0.09).setFill() NSRect(x: bounds.maxX - 1, y: bounds.minY, width: 1, height: bounds.height).fill() drawHashMarksAndLabels(in: dirtyRect) } override func drawHashMarksAndLabels(in rect: NSRect) { guard let tv = textView, let lm = tv.layoutManager, let textContainer = tv.textContainer else { return } rebuildLineCacheIfNeeded() let fullString = tv.string as NSString let textLength = fullString.length let visibleRect = tv.visibleRect let tcOrigin = tv.textContainerOrigin guard textLength >= 0 else { return } if textLength == 0 { let numberString = NSString(string: "1") let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: lineNumberColor ] let size = numberString.size(withAttributes: attributes) let drawPoint = NSPoint(x: bounds.maxX - size.width - inset, y: tcOrigin.y + 2) numberString.draw(at: drawPoint, withAttributes: attributes) return } let visibleRectInContainer = visibleRect.offsetBy(dx: -tcOrigin.x, dy: -tcOrigin.y) let visibleGlyphRange = lm.glyphRange(forBoundingRect: visibleRectInContainer, in: textContainer) guard visibleGlyphRange.location != NSNotFound else { return } var drawnLineStarts = Set() lm.enumerateLineFragments(forGlyphRange: visibleGlyphRange) { [self] _, usedRect, _, glyphRange, _ in guard glyphRange.location != NSNotFound else { return } let charRange = lm.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) guard charRange.location != NSNotFound, charRange.location < textLength else { return } let lineRange = fullString.lineRange(for: NSRange(location: charRange.location, length: 0)) let lineStart = lineRange.location if drawnLineStarts.contains(lineStart) { return } drawnLineStarts.insert(lineStart) let lineNumber = lineNumber(forCharacterLocation: lineStart) let numberString = NSString(string: "\(lineNumber)") let attributes: [NSAttributedString.Key: Any] = [ .font: self.font, .foregroundColor: self.lineNumberColor ] let size = numberString.size(withAttributes: attributes) let lineRectInView = NSRect( x: usedRect.origin.x + tcOrigin.x, y: usedRect.origin.y + tcOrigin.y, width: usedRect.size.width, height: usedRect.size.height ) let originInRuler = self.convert(NSPoint(x: 0, y: lineRectInView.minY), from: tv) let drawY = originInRuler.y + (lineRectInView.height - size.height) / 2.0 let drawPoint = NSPoint(x: self.bounds.maxX - size.width - self.inset, y: drawY) numberString.draw(at: drawPoint, withAttributes: attributes) } // Keep the last line number visible near end-of-document/bottom-scroll edge cases // where AppKit can report an empty visible glyph range. if drawnLineStarts.isEmpty, textLength > 0 { let lastLineNumber = max(1, cachedLineStarts.count) let numberString = NSString(string: "\(lastLineNumber)") let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: lineNumberColor ] let size = numberString.size(withAttributes: attributes) let drawY = max(bounds.minY + 2, bounds.maxY - size.height - 6) let drawPoint = NSPoint(x: bounds.maxX - size.width - inset, y: drawY) numberString.draw(at: drawPoint, withAttributes: attributes) } } private func installObservers(textView: NSTextView) { let center = NotificationCenter.default observers.append(RulerObserverToken(raw: center.addObserver( forName: NSText.didChangeNotification, object: textView, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in guard let self else { return } self.needsLineCacheRebuild = true self.updateRuleThicknessIfNeeded() self.needsDisplay = true } })) observers.append(RulerObserverToken(raw: center.addObserver( forName: NSView.boundsDidChangeNotification, object: textView.enclosingScrollView?.contentView, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in guard let self else { return } self.updateRuleThicknessIfNeeded() self.needsDisplay = true } })) observers.append(RulerObserverToken(raw: center.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in guard let self else { return } self.updateRuleThicknessIfNeeded() self.needsDisplay = true } })) observers.append(RulerObserverToken(raw: center.addObserver( forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in guard let self else { return } self.updateRuleThicknessIfNeeded() self.needsDisplay = true } })) } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() needsDisplay = true } @discardableResult private func updateRuleThicknessIfNeeded() -> Bool { rebuildLineCacheIfNeeded() let lineCount = max(1, cachedLineStarts.count) let digits = max(2, String(lineCount).count) let glyphWidth = NSString(string: "8").size(withAttributes: [.font: font]).width let targetThickness = ceil((glyphWidth * CGFloat(digits)) + (inset * 2) + 8) cachedDigitCount = digits if abs(ruleThickness - targetThickness) > 0.5 { ruleThickness = targetThickness scrollView?.tile() return true } return false } @MainActor func forceRulerLayoutRefresh() { needsLineCacheRebuild = true let didRetileFromThickness = updateRuleThicknessIfNeeded() if !didRetileFromThickness { scrollView?.tile() } needsDisplay = true } // Keep line-number lookup O(log n) while scrolling by caching UTF-16 line starts. private func rebuildLineCacheIfNeeded() { guard let tv = textView else { return } let text = tv.string if !needsLineCacheRebuild, cachedTextLength == (text as NSString).length { return } var starts: [Int] = [0] starts.reserveCapacity(max(16, cachedLineStarts.count)) var utf16Index = 0 for unit in text.utf16 { if unit == 10 { // '\n' starts.append(utf16Index + 1) } utf16Index += 1 } cachedLineStarts = starts cachedTextLength = utf16Index needsLineCacheRebuild = false } private func lineNumber(forCharacterLocation location: Int) -> Int { guard !cachedLineStarts.isEmpty else { return 1 } let clampedLocation = max(0, min(location, cachedTextLength)) var low = 0 var high = cachedLineStarts.count - 1 var best = 0 while low <= high { let mid = (low + high) / 2 if cachedLineStarts[mid] <= clampedLocation { best = mid low = mid + 1 } else { high = mid - 1 } } return best + 1 } } #endif