custom layout manager with code block backgrounds and checkbox rendering

This commit is contained in:
jess 2026-04-04 22:58:02 -07:00
parent 39feb208ab
commit 17f01722a4
1 changed files with 82 additions and 11 deletions

View File

@ -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))
} }