mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
261 lines
9.5 KiB
Swift
261 lines
9.5 KiB
Swift
#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()
|
|
updateRuleThicknessIfNeeded()
|
|
|
|
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<Int>()
|
|
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
|
|
}
|
|
|
|
private func updateRuleThicknessIfNeeded() {
|
|
rebuildLineCacheIfNeeded()
|
|
let lineCount = max(1, cachedLineStarts.count)
|
|
let digits = max(2, String(lineCount).count)
|
|
guard digits != cachedDigitCount else { return }
|
|
|
|
cachedDigitCount = digits
|
|
let glyphWidth = NSString(string: "8").size(withAttributes: [.font: font]).width
|
|
let targetThickness = ceil((glyphWidth * CGFloat(digits)) + (inset * 2) + 8)
|
|
if abs(ruleThickness - targetThickness) > 0.5 {
|
|
ruleThickness = targetThickness
|
|
scrollView?.tile()
|
|
}
|
|
}
|
|
|
|
// 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
|