custom layout manager with code block backgrounds
This commit is contained in:
parent
6d166a0f0b
commit
85af7f1029
|
|
@ -1,6 +1,151 @@
|
|||
import SwiftUI
|
||||
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 {
|
||||
@ObservedObject var state: AppState
|
||||
|
||||
|
|
@ -28,7 +173,14 @@ struct EditorTextView: NSViewRepresentable {
|
|||
scrollView.autohidesScrollers = true
|
||||
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.isSelectable = true
|
||||
textView.allowsUndo = true
|
||||
|
|
@ -81,6 +233,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
applySyntaxHighlighting(to: ts)
|
||||
ts.endEditing()
|
||||
}
|
||||
updateBlockRanges(for: textView)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
context.coordinator.triggerImageUpdate()
|
||||
|
|
@ -100,6 +253,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
ts.endEditing()
|
||||
}
|
||||
textView.selectedRanges = selectedRanges
|
||||
updateBlockRanges(for: textView)
|
||||
}
|
||||
textView.font = Theme.editorFont
|
||||
textView.textColor = Theme.current.text
|
||||
|
|
@ -131,6 +285,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
applySyntaxHighlighting(to: ts)
|
||||
ts.endEditing()
|
||||
tv.selectedRanges = sel
|
||||
updateBlockRanges(for: tv)
|
||||
rulerView?.needsDisplay = true
|
||||
|
||||
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
|
||||
|
||||
private let syntaxKeywords: Set<String> = [
|
||||
|
|
@ -585,9 +826,7 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp
|
|||
fencedRanges.append(blockRange)
|
||||
openFence = nil
|
||||
} else {
|
||||
// Content inside fenced block
|
||||
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
|
||||
textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: lineRange)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue