From 85af7f1029b176127f32168869dd47c2004f20be Mon Sep 17 00:00:00 2001 From: jess Date: Sat, 4 Apr 2026 22:43:03 -0700 Subject: [PATCH] custom layout manager with code block backgrounds --- src/EditorView.swift | 245 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 3 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index e509ad2..e6c55aa 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -1,6 +1,151 @@ import SwiftUI 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.. 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 { @ObservedObject var state: AppState @@ -28,7 +173,14 @@ struct EditorTextView: NSViewRepresentable { scrollView.autohidesScrollers = true 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.isSelectable = true textView.allowsUndo = true @@ -81,6 +233,7 @@ struct EditorTextView: NSViewRepresentable { applySyntaxHighlighting(to: ts) ts.endEditing() } + updateBlockRanges(for: textView) DispatchQueue.main.async { context.coordinator.triggerImageUpdate() @@ -100,6 +253,7 @@ struct EditorTextView: NSViewRepresentable { ts.endEditing() } textView.selectedRanges = selectedRanges + updateBlockRanges(for: textView) } textView.font = Theme.editorFont textView.textColor = Theme.current.text @@ -131,6 +285,7 @@ struct EditorTextView: NSViewRepresentable { applySyntaxHighlighting(to: ts) ts.endEditing() tv.selectedRanges = sel + updateBlockRanges(for: tv) rulerView?.needsDisplay = true 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 private let syntaxKeywords: Set = [ @@ -585,9 +826,7 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp fencedRanges.append(blockRange) openFence = nil } else { - // Content inside fenced block textStorage.addAttribute(.font, value: monoFont, range: lineRange) - textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: lineRange) } }