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 codeBlock
|
||||||
case blockquote
|
case blockquote
|
||||||
case horizontalRule
|
case horizontalRule
|
||||||
|
case checkbox(checked: Bool)
|
||||||
case tableBlock(columns: Int)
|
case tableBlock(columns: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,12 +35,45 @@ class MarkdownLayoutManager: NSLayoutManager {
|
||||||
drawBlockquoteBorder(glyphRange: glyphRange, origin: origin, container: textContainer)
|
drawBlockquoteBorder(glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||||
case .horizontalRule:
|
case .horizontalRule:
|
||||||
drawHorizontalRule(glyphRange: glyphRange, origin: origin, container: textContainer)
|
drawHorizontalRule(glyphRange: glyphRange, origin: origin, container: textContainer)
|
||||||
case .tableBlock(let columns):
|
case .checkbox, .tableBlock:
|
||||||
drawTableBorders(glyphRange: glyphRange, columns: columns, origin: origin, container: textContainer)
|
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) {
|
private func drawCodeBlockBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
|
||||||
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
||||||
rect.origin.x = origin.x + 4
|
rect.origin.x = origin.x + 4
|
||||||
|
|
@ -82,6 +116,34 @@ class MarkdownLayoutManager: NSLayoutManager {
|
||||||
path.stroke()
|
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) {
|
private func drawTableBorders(glyphRange: NSRange, columns: Int, origin: NSPoint, container: NSTextContainer) {
|
||||||
guard columns > 0 else { return }
|
guard columns > 0 else { return }
|
||||||
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
||||||
|
|
@ -100,7 +162,6 @@ class MarkdownLayoutManager: NSLayoutManager {
|
||||||
let tableText = text.substring(with: charRange)
|
let tableText = text.substring(with: charRange)
|
||||||
let lines = tableText.components(separatedBy: "\n").filter { !$0.isEmpty }
|
let lines = tableText.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||||
|
|
||||||
// Draw horizontal row separators
|
|
||||||
var charOffset = charRange.location
|
var charOffset = charRange.location
|
||||||
for (i, line) in lines.enumerated() {
|
for (i, line) in lines.enumerated() {
|
||||||
let lineLen = (line as NSString).length
|
let lineLen = (line as NSString).length
|
||||||
|
|
@ -118,7 +179,6 @@ class MarkdownLayoutManager: NSLayoutManager {
|
||||||
charOffset += lineLen + 1
|
charOffset += lineLen + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw vertical column separators using pipe character glyph positions
|
|
||||||
if let firstLine = lines.first {
|
if let firstLine = lines.first {
|
||||||
let nsFirstLine = firstLine as NSString
|
let nsFirstLine = firstLine as NSString
|
||||||
var pipeOffsets: [Int] = []
|
var pipeOffsets: [Int] = []
|
||||||
|
|
@ -127,7 +187,6 @@ class MarkdownLayoutManager: NSLayoutManager {
|
||||||
pipeOffsets.append(i)
|
pipeOffsets.append(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Skip first and last pipe (outer borders), draw inner separators
|
|
||||||
if pipeOffsets.count > 2 {
|
if pipeOffsets.count > 2 {
|
||||||
for pi in 1..<(pipeOffsets.count - 1) {
|
for pi in 1..<(pipeOffsets.count - 1) {
|
||||||
let charPos = charRange.location + pipeOffsets[pi]
|
let charPos = charRange.location + pipeOffsets[pi]
|
||||||
|
|
@ -143,7 +202,6 @@ class MarkdownLayoutManager: NSLayoutManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header background
|
|
||||||
if lines.count > 1 {
|
if lines.count > 1 {
|
||||||
let firstLineRange = NSRange(location: charRange.location, length: nsFirstLine.length)
|
let firstLineRange = NSRange(location: charRange.location, length: nsFirstLine.length)
|
||||||
let headerGlyphRange = self.glyphRange(forCharacterRange: firstLineRange, actualCharacterRange: nil)
|
let headerGlyphRange = self.glyphRange(forCharacterRange: firstLineRange, actualCharacterRange: nil)
|
||||||
|
|
@ -418,6 +476,10 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
|
|
||||||
// MARK: - Block Range Detection
|
// MARK: - Block Range Detection
|
||||||
|
|
||||||
|
private let checkboxPattern: NSRegularExpression? = {
|
||||||
|
try? NSRegularExpression(pattern: "\\[[ xX]\\]")
|
||||||
|
}()
|
||||||
|
|
||||||
func updateBlockRanges(for textView: NSTextView) {
|
func updateBlockRanges(for textView: NSTextView) {
|
||||||
guard let lm = textView.layoutManager as? MarkdownLayoutManager else { return }
|
guard let lm = textView.layoutManager as? MarkdownLayoutManager else { return }
|
||||||
let text = textView.string as NSString
|
let text = textView.string as NSString
|
||||||
|
|
@ -440,7 +502,6 @@ func updateBlockRanges(for textView: NSTextView) {
|
||||||
let line = text.substring(with: lineRange)
|
let line = text.substring(with: lineRange)
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
// Fenced code blocks
|
|
||||||
if openFence == nil && trimmed.hasPrefix("```") {
|
if openFence == nil && trimmed.hasPrefix("```") {
|
||||||
openFence = lineRange.location
|
openFence = lineRange.location
|
||||||
} else if openFence != nil && trimmed == "```" {
|
} else if openFence != nil && trimmed == "```" {
|
||||||
|
|
@ -456,7 +517,6 @@ func updateBlockRanges(for textView: NSTextView) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blockquotes
|
|
||||||
if trimmed.hasPrefix("> ") || trimmed == ">" {
|
if trimmed.hasPrefix("> ") || trimmed == ">" {
|
||||||
if blockquoteStart == nil { blockquoteStart = lineRange.location }
|
if blockquoteStart == nil { blockquoteStart = lineRange.location }
|
||||||
blockquoteEnd = NSMaxRange(lineRange)
|
blockquoteEnd = NSMaxRange(lineRange)
|
||||||
|
|
@ -467,12 +527,24 @@ func updateBlockRanges(for textView: NSTextView) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizontal rules
|
|
||||||
if isHorizontalRule(trimmed) && openFence == nil {
|
if isHorizontalRule(trimmed) && openFence == nil {
|
||||||
blocks.append(.init(range: lineRange, kind: .horizontalRule))
|
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 trimmed.hasPrefix("|") {
|
||||||
if tableStart == nil {
|
if tableStart == nil {
|
||||||
tableStart = lineRange.location
|
tableStart = lineRange.location
|
||||||
|
|
@ -491,7 +563,6 @@ func updateBlockRanges(for textView: NSTextView) {
|
||||||
lineStart = NSMaxRange(lineRange)
|
lineStart = NSMaxRange(lineRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush pending blocks
|
|
||||||
if let start = blockquoteStart {
|
if let start = blockquoteStart {
|
||||||
blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote))
|
blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue