custom layout manager with code block backgrounds

This commit is contained in:
jess 2026-04-04 22:43:03 -07:00
parent 6d166a0f0b
commit 85af7f1029
1 changed files with 242 additions and 3 deletions

View File

@ -1,6 +1,151 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
// MARK: - MarkdownLayoutManager
class MarkdownLayoutManager: NSLayoutManager {
struct BlockRange {
let range: NSRange
let kind: BlockKind
}
enum BlockKind {
case codeBlock
case blockquote
case horizontalRule
case tableBlock(columns: Int)
}
var blockRanges: [BlockRange] = []
override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) {
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
guard let textContainer = textContainers.first else { return }
for block in blockRanges {
let glyphRange = self.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
guard NSIntersectionRange(glyphRange, glyphsToShow).length > 0 else { continue }
switch block.kind {
case .codeBlock:
drawCodeBlockBackground(glyphRange: glyphRange, origin: origin, container: textContainer)
case .blockquote:
drawBlockquoteBorder(glyphRange: glyphRange, origin: origin, container: textContainer)
case .horizontalRule:
drawHorizontalRule(glyphRange: glyphRange, origin: origin, container: textContainer)
case .tableBlock(let columns):
drawTableBorders(glyphRange: glyphRange, columns: columns, origin: origin, container: textContainer)
}
}
}
private func drawCodeBlockBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
rect.origin.x = origin.x + 4
rect.origin.y += origin.y - 4
rect.size.width = container.containerSize.width - 8
rect.size.height += 8
let path = NSBezierPath(roundedRect: rect, xRadius: 6, yRadius: 6)
Theme.current.surface0.setFill()
path.fill()
Theme.current.surface1.setStroke()
path.lineWidth = 1
path.stroke()
}
private func drawBlockquoteBorder(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
rect.origin.x = origin.x
rect.origin.y += origin.y
rect.size.width = container.containerSize.width
let bgRect = NSRect(x: rect.origin.x + 8, y: rect.origin.y, width: rect.size.width - 16, height: rect.size.height)
Theme.current.surface0.withAlphaComponent(0.3).setFill()
bgRect.fill()
let barRect = NSRect(x: origin.x + 8, y: rect.origin.y, width: 3, height: rect.size.height)
Theme.current.lavender.setFill()
barRect.fill()
}
private func drawHorizontalRule(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
let rect = boundingRect(forGlyphRange: glyphRange, in: container)
let y = rect.origin.y + origin.y + rect.size.height / 2
let path = NSBezierPath()
path.move(to: NSPoint(x: origin.x + 8, y: y))
path.line(to: NSPoint(x: origin.x + container.containerSize.width - 8, y: y))
path.lineWidth = 1
Theme.current.overlay0.setStroke()
path.stroke()
}
private func drawTableBorders(glyphRange: NSRange, columns: Int, origin: NSPoint, container: NSTextContainer) {
guard columns > 0 else { return }
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
rect.origin.x = origin.x + 4
rect.origin.y += origin.y
rect.size.width = container.containerSize.width - 8
let outerPath = NSBezierPath(rect: rect)
outerPath.lineWidth = 1
Theme.current.surface2.setStroke()
outerPath.stroke()
guard let ts = textStorage else { return }
let charRange = characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
let text = ts.string as NSString
let tableText = text.substring(with: charRange)
let lines = tableText.components(separatedBy: "\n").filter { !$0.isEmpty }
// Draw horizontal row separators
var charOffset = charRange.location
for (i, line) in lines.enumerated() {
let lineLen = (line as NSString).length
if i > 0 {
let lineGlyphRange = self.glyphRange(forCharacterRange: NSRange(location: charOffset, length: 1), actualCharacterRange: nil)
let lineRect = boundingRect(forGlyphRange: lineGlyphRange, in: container)
let y = lineRect.origin.y + origin.y
let rowLine = NSBezierPath()
rowLine.move(to: NSPoint(x: rect.origin.x, y: y))
rowLine.line(to: NSPoint(x: rect.origin.x + rect.size.width, y: y))
rowLine.lineWidth = 0.5
Theme.current.surface2.setStroke()
rowLine.stroke()
}
charOffset += lineLen + 1
}
// Draw vertical column separators from first line pipe positions
if let firstLine = lines.first {
let colWidth = rect.size.width / CGFloat(max(columns, 1))
for col in 1..<columns {
let x = rect.origin.x + colWidth * CGFloat(col)
let colLine = NSBezierPath()
colLine.move(to: NSPoint(x: x, y: rect.origin.y))
colLine.line(to: NSPoint(x: x, y: rect.origin.y + rect.size.height))
colLine.lineWidth = 0.5
Theme.current.surface2.setStroke()
colLine.stroke()
}
// Header background
if lines.count > 1 {
let firstLineRange = NSRange(location: charRange.location, length: (firstLine as NSString).length)
let headerGlyphRange = self.glyphRange(forCharacterRange: firstLineRange, actualCharacterRange: nil)
var headerRect = boundingRect(forGlyphRange: headerGlyphRange, in: container)
headerRect.origin.x = rect.origin.x
headerRect.origin.y += origin.y
headerRect.size.width = rect.size.width
Theme.current.surface0.setFill()
headerRect.fill()
}
}
}
}
struct EditorView: View { struct EditorView: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@ -28,7 +173,14 @@ struct EditorTextView: NSViewRepresentable {
scrollView.autohidesScrollers = true scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder scrollView.borderType = .noBorder
let textView = LineNumberTextView() 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)
let textView = LineNumberTextView(frame: .zero, textContainer: textContainer)
textView.isEditable = true textView.isEditable = true
textView.isSelectable = true textView.isSelectable = true
textView.allowsUndo = true textView.allowsUndo = true
@ -81,6 +233,7 @@ struct EditorTextView: NSViewRepresentable {
applySyntaxHighlighting(to: ts) applySyntaxHighlighting(to: ts)
ts.endEditing() ts.endEditing()
} }
updateBlockRanges(for: textView)
DispatchQueue.main.async { DispatchQueue.main.async {
context.coordinator.triggerImageUpdate() context.coordinator.triggerImageUpdate()
@ -100,6 +253,7 @@ struct EditorTextView: NSViewRepresentable {
ts.endEditing() ts.endEditing()
} }
textView.selectedRanges = selectedRanges textView.selectedRanges = selectedRanges
updateBlockRanges(for: textView)
} }
textView.font = Theme.editorFont textView.font = Theme.editorFont
textView.textColor = Theme.current.text textView.textColor = Theme.current.text
@ -131,6 +285,7 @@ struct EditorTextView: NSViewRepresentable {
applySyntaxHighlighting(to: ts) applySyntaxHighlighting(to: ts)
ts.endEditing() ts.endEditing()
tv.selectedRanges = sel tv.selectedRanges = sel
updateBlockRanges(for: tv)
rulerView?.needsDisplay = true rulerView?.needsDisplay = true
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
@ -249,6 +404,92 @@ struct EditorTextView: NSViewRepresentable {
} }
} }
// MARK: - Block Range Detection
func updateBlockRanges(for textView: NSTextView) {
guard let lm = textView.layoutManager as? MarkdownLayoutManager else { return }
let text = textView.string as NSString
guard text.length > 0 else {
lm.blockRanges = []
return
}
var blocks: [MarkdownLayoutManager.BlockRange] = []
var lineStart = 0
var openFence: Int? = nil
var blockquoteStart: Int? = nil
var blockquoteEnd: Int = 0
var tableStart: Int? = nil
var tableEnd: Int = 0
var tableColumns: Int = 0
while lineStart < text.length {
let lineRange = text.lineRange(for: NSRange(location: lineStart, length: 0))
let line = text.substring(with: lineRange)
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
// Fenced code blocks
if openFence == nil && trimmed.hasPrefix("```") {
openFence = lineRange.location
} else if openFence != nil && trimmed == "```" {
let range = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!)
blocks.append(.init(range: range, kind: .codeBlock))
openFence = nil
lineStart = NSMaxRange(lineRange)
continue
}
if openFence != nil {
lineStart = NSMaxRange(lineRange)
continue
}
// Blockquotes
if trimmed.hasPrefix("> ") || trimmed == ">" {
if blockquoteStart == nil { blockquoteStart = lineRange.location }
blockquoteEnd = NSMaxRange(lineRange)
} else {
if let start = blockquoteStart {
blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote))
blockquoteStart = nil
}
}
// Horizontal rules
if isHorizontalRule(trimmed) && openFence == nil {
blocks.append(.init(range: lineRange, kind: .horizontalRule))
}
// Tables
if trimmed.hasPrefix("|") {
if tableStart == nil {
tableStart = lineRange.location
tableColumns = trimmed.filter({ $0 == "|" }).count - 1
if tableColumns < 1 { tableColumns = 1 }
}
tableEnd = NSMaxRange(lineRange)
} else {
if let start = tableStart {
blocks.append(.init(range: NSRange(location: start, length: tableEnd - start), kind: .tableBlock(columns: tableColumns)))
tableStart = nil
tableColumns = 0
}
}
lineStart = NSMaxRange(lineRange)
}
// Flush pending blocks
if let start = blockquoteStart {
blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote))
}
if let start = tableStart {
blocks.append(.init(range: NSRange(location: start, length: tableEnd - start), kind: .tableBlock(columns: tableColumns)))
}
lm.blockRanges = blocks
}
// MARK: - Syntax Highlighting // MARK: - Syntax Highlighting
private let syntaxKeywords: Set<String> = [ private let syntaxKeywords: Set<String> = [
@ -585,9 +826,7 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp
fencedRanges.append(blockRange) fencedRanges.append(blockRange)
openFence = nil openFence = nil
} else { } else {
// Content inside fenced block
textStorage.addAttribute(.font, value: monoFont, range: lineRange) textStorage.addAttribute(.font, value: monoFont, range: lineRange)
textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: lineRange)
} }
} }