custom layout manager with code block backgrounds
This commit is contained in:
parent
6d166a0f0b
commit
85af7f1029
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue