Neon-Vision-Editor/Neon Vision Editor/LineNumberRulerView.swift

145 lines
5.2 KiB
Swift
Raw Normal View History

//
// LineNumberRulerView.swift
// Neon Vision Editor
//
// Created by h3pdesign on 06.02.26.
//
#if os(macOS)
import AppKit
final class LineNumberRulerView: NSRulerView {
weak var textView: NSTextView?
private let font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
private let textColor = NSColor.secondaryLabelColor
private let inset: CGFloat = 6
init(textView: NSTextView) {
self.textView = textView
super.init(scrollView: textView.enclosingScrollView, orientation: .verticalRuler)
self.clientView = textView
self.ruleThickness = 48
// Ensure we get bounds-changed notifications while scrolling
textView.enclosingScrollView?.contentView.postsBoundsChangedNotifications = true
NotificationCenter.default.addObserver(
self,
selector: #selector(needsRedraw),
name: NSView.boundsDidChangeNotification,
object: textView.enclosingScrollView?.contentView
)
}
required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
@objc private func needsRedraw() {
needsDisplay = true
}
// Keep the ruler transparent so the window's translucency/vibrancy shows through.
override var isOpaque: Bool { false }
override func draw(_ dirtyRect: NSRect) {
// Do not paint an opaque background.
NSColor.clear.setFill()
dirtyRect.fill()
// Draw only the ruler contents (line numbers).
drawHashMarksAndLabels(in: dirtyRect)
}
override func drawHashMarksAndLabels(in rect: NSRect) {
guard
let tv = textView,
let lm = tv.layoutManager
else { return }
let fullString = tv.string as NSString
let visibleRect = tv.visibleRect
let tcOrigin = tv.textContainerOrigin // Accounts for textContainerInset
// Find the first visible character using a probe point inside the text container
// (not inside the left inset / ruler area)
let probePoint = NSPoint(x: tcOrigin.x + 2, y: visibleRect.minY + 2)
let firstVisibleCharIndex = tv.characterIndexForInsertion(at: probePoint)
let clampedCharIndex = min(max(firstVisibleCharIndex, 0), fullString.length)
// Compute the line number of the first visible logical line
// by counting newline characters up to the visible character index
let prefix = fullString.substring(to: clampedCharIndex)
var currentLineNumber = prefix.reduce(1) { $1 == "\n" ? $0 + 1 : $0 }
// Start at the logical line containing the first visible character
var charIndex = fullString.lineRange(
for: NSRange(location: clampedCharIndex, length: 0)
).location
while charIndex < fullString.length {
let lineRange = fullString.lineRange(
for: NSRange(location: charIndex, length: 0)
)
// Ensure layout information is available for this logical line
lm.ensureLayout(forCharacterRange: lineRange)
let glyphRange = lm.glyphRange(
forCharacterRange: lineRange,
actualCharacterRange: nil
)
if glyphRange.location >= lm.numberOfGlyphs { break }
var effectiveGlyphRange = NSRange(location: 0, length: 0)
// Get the visual rect for the first glyph of the logical line
let lineRectInContainer = lm.lineFragmentRect(
forGlyphAt: glyphRange.location,
effectiveRange: &effectiveGlyphRange,
withoutAdditionalLayout: false
)
// Convert from text container coordinates to view coordinates
let lineRectInView = NSRect(
x: lineRectInContainer.origin.x + tcOrigin.x,
y: lineRectInContainer.origin.y + tcOrigin.y,
width: lineRectInContainer.size.width,
height: lineRectInContainer.size.height
)
// Stop once we are below the visible viewport
if lineRectInView.minY > visibleRect.maxY { break }
// Draw only lines that intersect the visible area
if lineRectInView.maxY >= visibleRect.minY {
let numberString = NSString(string: "\(currentLineNumber)")
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: textColor
]
let size = numberString.size(withAttributes: attributes)
// Position the line number vertically centered relative to the line fragment
let drawY =
(lineRectInView.minY - visibleRect.minY)
+ bounds.minY
+ (lineRectInView.height - size.height) / 2.0
let drawPoint = NSPoint(
x: bounds.maxX - size.width - inset,
y: drawY
)
numberString.draw(at: drawPoint, withAttributes: attributes)
}
// Advance to the next logical line
charIndex = lineRange.upperBound
currentLineNumber += 1
}
}
}
#endif