diff --git a/src/EditorView.swift b/src/EditorView.swift index 59e7d41..f21740a 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -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)) }