fix invisible text by replacing NSRulerView with integrated gutter drawing

This commit is contained in:
jess 2026-04-05 03:03:28 -07:00
parent 5a715fa480
commit 695571e90e
1 changed files with 91 additions and 130 deletions

View File

@ -537,7 +537,6 @@ struct EditorView: View {
EditorTextView(text: $state.documentText, evalResults: state.evalResults, onEvaluate: {
state.evaluate()
})
.background(Color(ns: Theme.current.base))
}
}
@ -551,20 +550,20 @@ struct EditorTextView: NSViewRepresentable {
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder
let scrollView = NSTextView.scrollableTextView()
let defaultTV = scrollView.documentView as! NSTextView
let textStorage = NSTextStorage()
let layoutManager = MarkdownLayoutManager()
let textContainer = NSTextContainer(containerSize: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
textContainer.widthTracksTextView = true
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
// Build LineNumberTextView reusing the default text container
let tc = defaultTV.textContainer!
tc.replaceLayoutManager(MarkdownLayoutManager())
let textView = LineNumberTextView(frame: defaultTV.frame, textContainer: tc)
textView.minSize = defaultTV.minSize
textView.maxSize = defaultTV.maxSize
textView.isVerticallyResizable = defaultTV.isVerticallyResizable
textView.isHorizontallyResizable = defaultTV.isHorizontallyResizable
textView.autoresizingMask = defaultTV.autoresizingMask
let textView = LineNumberTextView(frame: .zero, textContainer: textContainer)
textView.isEditable = true
textView.isSelectable = true
textView.allowsUndo = true
@ -585,33 +584,16 @@ struct EditorTextView: NSViewRepresentable {
textView.smartInsertDeleteEnabled = false
textView.isAutomaticLinkDetectionEnabled = false
textView.autoresizingMask = [.width]
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
textView.textContainerInset = NSSize(width: 4, height: 8)
textView.textContainer?.widthTracksTextView = true
textView.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.registerForDraggedTypes([.fileURL])
scrollView.documentView = textView
let ruler = LineNumberRulerView(textView: textView)
ruler.evalResults = evalResults
scrollView.verticalRulerView = ruler
scrollView.hasVerticalRuler = true
scrollView.rulersVisible = true
textView.string = text
textView.evalResults = evalResults
textView.delegate = context.coordinator
context.coordinator.textView = textView
context.coordinator.rulerView = ruler
if let ts = textView.textStorage {
ts.beginEditing()
@ -652,16 +634,13 @@ struct EditorTextView: NSViewRepresentable {
.foregroundColor: Theme.current.text
]
if let ruler = scrollView.verticalRulerView as? LineNumberRulerView {
ruler.evalResults = evalResults
ruler.needsDisplay = true
}
textView.evalResults = evalResults
textView.needsDisplay = true
}
class Coordinator: NSObject, NSTextViewDelegate {
var parent: EditorTextView
weak var textView: NSTextView?
weak var rulerView: LineNumberRulerView?
private var isUpdatingImages = false
private var isUpdatingTables = false
private var embeddedTableViews: [MarkdownTableView] = []
@ -684,7 +663,7 @@ struct EditorTextView: NSViewRepresentable {
]
tv.selectedRanges = sel
updateBlockRanges(for: tv)
rulerView?.needsDisplay = true
tv.needsDisplay = true
DispatchQueue.main.async { [weak self] in
self?.updateInlineImages()
@ -1042,6 +1021,7 @@ private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: N
if contentRange.length > 0 {
let h2Font = NSFont.systemFont(ofSize: 18, weight: .bold)
textStorage.addAttribute(.font, value: h2Font, range: contentRange)
textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange)
}
}
return true
@ -1056,6 +1036,7 @@ private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: N
if contentRange.length > 0 {
let h1Font = NSFont.systemFont(ofSize: 22, weight: .bold)
textStorage.addAttribute(.font, value: h1Font, range: contentRange)
textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange)
}
}
return true
@ -1167,6 +1148,7 @@ private func highlightInlineMarkdown(textStorage: NSTextStorage, lineRange: NSRa
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeMarker)
if contentRange.length > 0 {
textStorage.addAttribute(.font, value: font, range: contentRange)
textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange)
}
}
}
@ -1311,6 +1293,7 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp
openFence = nil
} else {
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange)
}
}
@ -1344,6 +1327,7 @@ private func highlightInlineCode(textStorage: NSTextStorage, lineRange: NSRange,
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeTick)
if contentRange.length > 0 {
textStorage.addAttribute(.font, value: monoFont, range: contentRange)
textStorage.addAttribute(.foregroundColor, value: palette.text, range: contentRange)
textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: contentRange)
}
}
@ -1398,6 +1382,7 @@ private func highlightTableLine(_ trimmed: String, lineRange: NSRange, textStora
let boldMono = NSFontManager.shared.convert(monoFont, toHaveTrait: .boldFontMask)
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange)
if isSeparator {
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
@ -1708,12 +1693,80 @@ private func resolveLocalImagePath(_ rawPath: String) -> String? {
// MARK: - LineNumberTextView
class LineNumberTextView: NSTextView {
static let gutterWidth: CGFloat = 50
var evalResults: [Int: String] = [:]
override var textContainerOrigin: NSPoint {
return NSPoint(x: LineNumberTextView.gutterWidth, y: textContainerInset.height)
}
override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
var widened = rect
widened.size.width = 2
super.drawInsertionPoint(in: widened, color: color, turnedOn: flag)
}
override func drawBackground(in rect: NSRect) {
super.drawBackground(in: rect)
let origin = textContainerOrigin
let gutterRect = NSRect(x: 0, y: rect.origin.y, width: LineNumberTextView.gutterWidth, height: rect.height)
Theme.current.mantle.setFill()
gutterRect.fill()
drawLineNumbers(origin: origin)
}
private func drawLineNumbers(origin: NSPoint) {
guard let lm = layoutManager, let tc = textContainer else { return }
let palette = Theme.current
let text = string as NSString
guard text.length > 0 else { return }
var containerVisible = visibleRect
containerVisible.origin.x -= origin.x
containerVisible.origin.y -= origin.y
let visibleGlyphs = lm.glyphRange(forBoundingRect: containerVisible, in: tc)
let visibleChars = lm.characterRange(forGlyphRange: visibleGlyphs, actualGlyphRange: nil)
var lineNumber = 1
var idx = 0
while idx < visibleChars.location {
if text.character(at: idx) == 0x0A { lineNumber += 1 }
idx += 1
}
let lineAttrs: [NSAttributedString.Key: Any] = [
.font: Theme.gutterFont,
.foregroundColor: palette.overlay0
]
let resultAttrs: [NSAttributedString.Key: Any] = [
.font: Theme.gutterFont,
.foregroundColor: palette.teal
]
var charIndex = visibleChars.location
while charIndex < NSMaxRange(visibleChars) {
let lineRange = text.lineRange(for: NSRange(location: charIndex, length: 0))
let lineGlyphRange = lm.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil)
let lineRect = lm.boundingRect(forGlyphRange: lineGlyphRange, in: tc)
let y = lineRect.origin.y + origin.y
if let result = evalResults[lineNumber - 1] {
let resultStr = NSAttributedString(string: "\u{2192} \(result)", attributes: resultAttrs)
let size = resultStr.size()
resultStr.draw(at: NSPoint(x: LineNumberTextView.gutterWidth - size.width - 4, y: y))
} else {
let numStr = NSAttributedString(string: "\(lineNumber)", attributes: lineAttrs)
let size = numStr.size()
numStr.draw(at: NSPoint(x: LineNumberTextView.gutterWidth - size.width - 8, y: y))
}
lineNumber += 1
charIndex = NSMaxRange(lineRange)
}
}
// MARK: - Paste (image from clipboard)
override func paste(_ sender: Any?) {
@ -1790,95 +1843,3 @@ class LineNumberTextView: NSTextView {
}
}
class LineNumberRulerView: NSRulerView {
var evalResults: [Int: String] = [:]
private weak var editorTextView: NSTextView?
init(textView: NSTextView) {
self.editorTextView = textView
super.init(scrollView: textView.enclosingScrollView!, orientation: .verticalRuler)
self.clientView = textView
self.ruleThickness = 50
NotificationCenter.default.addObserver(
self,
selector: #selector(textDidChange(_:)),
name: NSText.didChangeNotification,
object: textView
)
}
required init(coder: NSCoder) {
fatalError()
}
@objc private func textDidChange(_ notification: Notification) {
needsDisplay = true
}
override func drawHashMarksAndLabels(in rect: NSRect) {
guard let tv = editorTextView,
let layoutManager = tv.layoutManager,
let textContainer = tv.textContainer else { return }
let palette = Theme.current
palette.mantle.setFill()
rect.fill()
let visibleRect = scrollView!.contentView.bounds
let visibleGlyphRange = layoutManager.glyphRange(
forBoundingRect: visibleRect, in: textContainer
)
let visibleCharRange = layoutManager.characterRange(
forGlyphRange: visibleGlyphRange, actualGlyphRange: nil
)
let text = tv.string as NSString
var lineNumber = 1
var index = 0
while index < visibleCharRange.location {
if text.character(at: index) == 0x0A { lineNumber += 1 }
index += 1
}
let attrs: [NSAttributedString.Key: Any] = [
.font: Theme.gutterFont,
.foregroundColor: palette.overlay0
]
let resultAttrs: [NSAttributedString.Key: Any] = [
.font: Theme.gutterFont,
.foregroundColor: palette.teal
]
var charIndex = visibleCharRange.location
while charIndex < NSMaxRange(visibleCharRange) {
let lineRange = text.lineRange(for: NSRange(location: charIndex, length: 0))
let glyphRange = layoutManager.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil)
var lineRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
lineRect.origin.y += tv.textContainerInset.height - visibleRect.origin.y
if let result = evalResults[lineNumber - 1] {
let resultStr = NSAttributedString(string: "\(result)", attributes: resultAttrs)
let resultSize = resultStr.size()
let resultPoint = NSPoint(
x: ruleThickness - resultSize.width - 4,
y: lineRect.origin.y
)
resultStr.draw(at: resultPoint)
} else {
let numStr = NSAttributedString(string: "\(lineNumber)", attributes: attrs)
let size = numStr.size()
let point = NSPoint(
x: ruleThickness - size.width - 8,
y: lineRect.origin.y
)
numStr.draw(at: point)
}
lineNumber += 1
charIndex = NSMaxRange(lineRange)
}
}
}