custom layout manager with code block backgrounds and checkbox rendering
This commit is contained in:
parent
39feb208ab
commit
17f01722a4
|
|
@ -13,6 +13,7 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
case codeBlock
|
||||
case blockquote
|
||||
case horizontalRule
|
||||
case checkbox(checked: Bool)
|
||||
case tableBlock(columns: Int)
|
||||
}
|
||||
|
||||
|
|
@ -34,12 +35,45 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
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)
|
||||
case .checkbox, .tableBlock:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) {
|
||||
guard let textContainer = textContainers.first else {
|
||||
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
|
||||
return
|
||||
}
|
||||
|
||||
var skipRanges: [NSRange] = []
|
||||
for block in blockRanges {
|
||||
guard case .checkbox(let checked) = block.kind else { continue }
|
||||
let glyphRange = self.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
||||
guard NSIntersectionRange(glyphRange, glyphsToShow).length > 0 else { continue }
|
||||
skipRanges.append(glyphRange)
|
||||
drawCheckbox(checked: checked, glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||
}
|
||||
|
||||
if skipRanges.isEmpty {
|
||||
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
|
||||
return
|
||||
}
|
||||
|
||||
skipRanges.sort { $0.location < $1.location }
|
||||
var cursor = glyphsToShow.location
|
||||
for skip in skipRanges {
|
||||
if cursor < skip.location {
|
||||
super.drawGlyphs(forGlyphRange: NSRange(location: cursor, length: skip.location - cursor), at: origin)
|
||||
}
|
||||
cursor = NSMaxRange(skip)
|
||||
}
|
||||
if cursor < NSMaxRange(glyphsToShow) {
|
||||
super.drawGlyphs(forGlyphRange: NSRange(location: cursor, length: NSMaxRange(glyphsToShow) - cursor), at: origin)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawCodeBlockBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
|
||||
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
||||
rect.origin.x = origin.x + 4
|
||||
|
|
@ -82,6 +116,34 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
path.stroke()
|
||||
}
|
||||
|
||||
private func drawCheckbox(checked: Bool, glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
|
||||
let rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
||||
let size: CGFloat = 14
|
||||
let x = rect.origin.x + origin.x
|
||||
let y = rect.origin.y + origin.y + (rect.size.height - size) / 2
|
||||
let boxRect = NSRect(x: x, y: y, width: size, height: size)
|
||||
let path = NSBezierPath(roundedRect: boxRect, xRadius: 3, yRadius: 3)
|
||||
|
||||
if checked {
|
||||
Theme.current.green.setFill()
|
||||
path.fill()
|
||||
|
||||
let check = NSBezierPath()
|
||||
check.move(to: NSPoint(x: x + 3, y: y + size / 2))
|
||||
check.line(to: NSPoint(x: x + size * 0.4, y: y + 3))
|
||||
check.line(to: NSPoint(x: x + size - 3, y: y + size - 3))
|
||||
check.lineWidth = 2
|
||||
check.lineCapStyle = .round
|
||||
check.lineJoinStyle = .round
|
||||
NSColor.white.setStroke()
|
||||
check.stroke()
|
||||
} else {
|
||||
Theme.current.overlay0.setStroke()
|
||||
path.lineWidth = 1.5
|
||||
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)
|
||||
|
|
@ -100,7 +162,6 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
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
|
||||
|
|
@ -118,7 +179,6 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
charOffset += lineLen + 1
|
||||
}
|
||||
|
||||
// Draw vertical column separators using pipe character glyph positions
|
||||
if let firstLine = lines.first {
|
||||
let nsFirstLine = firstLine as NSString
|
||||
var pipeOffsets: [Int] = []
|
||||
|
|
@ -127,7 +187,6 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
pipeOffsets.append(i)
|
||||
}
|
||||
}
|
||||
// Skip first and last pipe (outer borders), draw inner separators
|
||||
if pipeOffsets.count > 2 {
|
||||
for pi in 1..<(pipeOffsets.count - 1) {
|
||||
let charPos = charRange.location + pipeOffsets[pi]
|
||||
|
|
@ -143,7 +202,6 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Header background
|
||||
if lines.count > 1 {
|
||||
let firstLineRange = NSRange(location: charRange.location, length: nsFirstLine.length)
|
||||
let headerGlyphRange = self.glyphRange(forCharacterRange: firstLineRange, actualCharacterRange: nil)
|
||||
|
|
@ -418,6 +476,10 @@ struct EditorTextView: NSViewRepresentable {
|
|||
|
||||
// MARK: - Block Range Detection
|
||||
|
||||
private let checkboxPattern: NSRegularExpression? = {
|
||||
try? NSRegularExpression(pattern: "\\[[ xX]\\]")
|
||||
}()
|
||||
|
||||
func updateBlockRanges(for textView: NSTextView) {
|
||||
guard let lm = textView.layoutManager as? MarkdownLayoutManager else { return }
|
||||
let text = textView.string as NSString
|
||||
|
|
@ -440,7 +502,6 @@ func updateBlockRanges(for textView: NSTextView) {
|
|||
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 == "```" {
|
||||
|
|
@ -456,7 +517,6 @@ func updateBlockRanges(for textView: NSTextView) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Blockquotes
|
||||
if trimmed.hasPrefix("> ") || trimmed == ">" {
|
||||
if blockquoteStart == nil { blockquoteStart = lineRange.location }
|
||||
blockquoteEnd = NSMaxRange(lineRange)
|
||||
|
|
@ -467,12 +527,24 @@ func updateBlockRanges(for textView: NSTextView) {
|
|||
}
|
||||
}
|
||||
|
||||
// Horizontal rules
|
||||
if isHorizontalRule(trimmed) && openFence == nil {
|
||||
blocks.append(.init(range: lineRange, kind: .horizontalRule))
|
||||
}
|
||||
|
||||
// Tables
|
||||
// Task list checkboxes
|
||||
let taskPrefixes = ["- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] "]
|
||||
let strippedLine = trimmed
|
||||
for prefix in taskPrefixes {
|
||||
if strippedLine.hasPrefix(prefix) {
|
||||
let checked = prefix.contains("x") || prefix.contains("X")
|
||||
if let regex = checkboxPattern,
|
||||
let match = regex.firstMatch(in: text as String, range: lineRange) {
|
||||
blocks.append(.init(range: match.range, kind: .checkbox(checked: checked)))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed.hasPrefix("|") {
|
||||
if tableStart == nil {
|
||||
tableStart = lineRange.location
|
||||
|
|
@ -491,7 +563,6 @@ func updateBlockRanges(for textView: NSTextView) {
|
|||
lineStart = NSMaxRange(lineRange)
|
||||
}
|
||||
|
||||
// Flush pending blocks
|
||||
if let start = blockquoteStart {
|
||||
blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue