From b6a007490e932cdd29aafcb23bcbd8f73a130902 Mon Sep 17 00:00:00 2001 From: jess Date: Tue, 7 Apr 2026 07:18:33 -0700 Subject: [PATCH] add block compositor foundation: protocol, view, text block, document model --- src/CompositorView.swift | 434 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 src/CompositorView.swift diff --git a/src/CompositorView.swift b/src/CompositorView.swift new file mode 100644 index 0000000..9f34163 --- /dev/null +++ b/src/CompositorView.swift @@ -0,0 +1,434 @@ +import SwiftUI +import AppKit + +// MARK: - Block Protocol + +protocol CompositorBlock: AnyObject { + var view: NSView { get } + var blockHeight: CGFloat { get } + func layoutBlock(width: CGFloat) + func becomeActiveBlock() + func resignActiveBlock() +} + +// MARK: - CompositorView + +class CompositorView: NSView { + + var blocks: [CompositorBlock] = [] + weak var scrollView: NSScrollView? + + var onContentHeightChanged: (() -> Void)? + + private var activeBlockIndex: Int? = nil + + override var isFlipped: Bool { true } + + func setBlocks(_ newBlocks: [CompositorBlock]) { + for block in blocks { + block.view.removeFromSuperview() + } + blocks = newBlocks + for block in blocks { + addSubview(block.view) + } + layoutAllBlocks() + } + + func insertBlock(_ block: CompositorBlock, at index: Int) { + let clamped = min(index, blocks.count) + blocks.insert(block, at: clamped) + addSubview(block.view) + layoutBlocks(from: clamped) + } + + func removeBlock(at index: Int) { + guard index < blocks.count else { return } + let block = blocks[index] + block.view.removeFromSuperview() + blocks.remove(at: index) + if activeBlockIndex == index { + activeBlockIndex = nil + } else if let active = activeBlockIndex, active > index { + activeBlockIndex = active - 1 + } + layoutBlocks(from: index) + } + + func layoutAllBlocks() { + layoutBlocks(from: 0) + } + + func layoutBlocks(from startIndex: Int) { + let width = bounds.width + var y: CGFloat = startIndex > 0 + ? blocks[startIndex - 1].view.frame.maxY + : 0 + + for i in startIndex..= 0, index < blocks.count else { return } + if let prev = activeBlockIndex, prev < blocks.count { + blocks[prev].resignActiveBlock() + } + activeBlockIndex = index + blocks[index].becomeActiveBlock() + } + + func activateNextBlock() { + let next = (activeBlockIndex ?? -1) + 1 + if next < blocks.count { + activateBlock(at: next) + } + } + + func activatePreviousBlock() { + let prev = (activeBlockIndex ?? blocks.count) - 1 + if prev >= 0 { + activateBlock(at: prev) + } + } + + var contentHeight: CGFloat { + blocks.last.map { $0.view.frame.maxY } ?? 0 + } +} + +// MARK: - TextBlock + +class TextBlock: NSObject, CompositorBlock, NSTextViewDelegate { + + let textView: NSTextView + private let textContainer: NSTextContainer + private let layoutManager: NSLayoutManager + private let textStorage: NSTextStorage + + weak var compositor: CompositorView? + + var view: NSView { textView } + + var blockHeight: CGFloat { + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + return max(usedRect.height + textView.textContainerInset.height * 2, 24) + } + + var text: String { + get { textView.string } + set { textView.string = newValue } + } + + override init() { + textStorage = NSTextStorage() + layoutManager = NSLayoutManager() + textStorage.addLayoutManager(layoutManager) + + textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) + textContainer.widthTracksTextView = true + layoutManager.addTextContainer(textContainer) + + textView = NSTextView(frame: .zero, textContainer: textContainer) + textView.isEditable = true + textView.isSelectable = true + textView.allowsUndo = true + textView.isRichText = false + textView.isVerticallyResizable = false + textView.isHorizontallyResizable = false + textView.font = Theme.editorFont + textView.textColor = Theme.current.text + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.insertionPointColor = Theme.current.text + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.smartInsertDeleteEnabled = false + textView.isAutomaticLinkDetectionEnabled = false + textView.textContainerInset = NSSize(width: 4, height: 4) + + super.init() + textView.delegate = self + } + + convenience init(text: String) { + self.init() + textView.string = text + } + + func layoutBlock(width: CGFloat) { + textContainer.size = NSSize(width: max(width - 8, 1), height: CGFloat.greatestFiniteMagnitude) + layoutManager.ensureLayout(for: textContainer) + } + + func becomeActiveBlock() { + textView.window?.makeFirstResponder(textView) + } + + func resignActiveBlock() { + if textView.window?.firstResponder === textView { + textView.window?.makeFirstResponder(nil) + } + } + + // MARK: - NSTextViewDelegate + + func textDidChange(_ notification: Notification) { + compositor?.blockDidResize(self) + } +} + +// MARK: - Document Model + +enum BlockType { + case text + case table + case horizontalRule +} + +struct BlockDescriptor { + let type: BlockType + let content: String +} + +func parseDocument(_ markdown: String) -> [BlockDescriptor] { + let lines = markdown.components(separatedBy: "\n") + var descriptors: [BlockDescriptor] = [] + var currentTextLines: [String] = [] + + func flushText() { + if !currentTextLines.isEmpty { + let content = currentTextLines.joined(separator: "\n") + descriptors.append(BlockDescriptor(type: .text, content: content)) + currentTextLines = [] + } + } + + var i = 0 + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Horizontal rule: line is only dashes/spaces, at least 3 dashes + if isHorizontalRuleLine(trimmed) { + flushText() + descriptors.append(BlockDescriptor(type: .horizontalRule, content: line)) + i += 1 + continue + } + + // Table: starts with | and next line is a separator row + if trimmed.hasPrefix("|") && i + 1 < lines.count { + let nextTrimmed = lines[i + 1].trimmingCharacters(in: .whitespaces) + if isDocTableSeparator(nextTrimmed) { + flushText() + var tableLines: [String] = [line, lines[i + 1]] + var j = i + 2 + while j < lines.count { + let rowTrimmed = lines[j].trimmingCharacters(in: .whitespaces) + if rowTrimmed.hasPrefix("|") && rowTrimmed.hasSuffix("|") { + tableLines.append(lines[j]) + j += 1 + } else { + break + } + } + let content = tableLines.joined(separator: "\n") + descriptors.append(BlockDescriptor(type: .table, content: content)) + i = j + continue + } + } + + currentTextLines.append(line) + i += 1 + } + + flushText() + + if descriptors.isEmpty { + descriptors.append(BlockDescriptor(type: .text, content: "")) + } + + return descriptors +} + +func serializeDocument(_ blocks: [CompositorBlock]) -> String { + var parts: [String] = [] + for block in blocks { + if let textBlock = block as? TextBlock { + parts.append(textBlock.text) + } else if let hrBlock = block as? HRBlock { + parts.append(hrBlock.sourceText) + } else { + parts.append("") + } + } + return parts.joined(separator: "\n") +} + +private func isHorizontalRuleLine(_ trimmed: String) -> Bool { + guard !trimmed.isEmpty else { return false } + let stripped = trimmed.replacingOccurrences(of: " ", with: "") + guard stripped.count >= 3 else { return false } + return stripped.allSatisfy { $0 == "-" } +} + +private func isDocTableSeparator(_ trimmed: String) -> Bool { + guard trimmed.hasPrefix("|") else { return false } + let inner = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "| ")) + guard !inner.isEmpty else { return false } + let cells = inner.components(separatedBy: "|") + return cells.allSatisfy { cell in + let c = cell.trimmingCharacters(in: .whitespaces) + return c.allSatisfy { $0 == "-" || $0 == ":" } && c.contains("-") + } +} + +// MARK: - HRBlock (placeholder for Stage 3, minimal for compilation) + +class HRDrawingView: NSView { + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let y = bounds.midY + let path = NSBezierPath() + path.move(to: NSPoint(x: 16, y: y)) + path.line(to: NSPoint(x: bounds.width - 16, y: y)) + path.lineWidth = 1 + Theme.current.overlay0.setStroke() + path.stroke() + } +} + +class HRBlock: NSObject, CompositorBlock { + + let sourceText: String + private let hrView: HRDrawingView + + var view: NSView { hrView } + + var blockHeight: CGFloat { 20 } + + init(sourceText: String = "---") { + self.sourceText = sourceText + hrView = HRDrawingView(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) + super.init() + } + + func layoutBlock(width: CGFloat) {} + + func becomeActiveBlock() {} + + func resignActiveBlock() {} +} + +// MARK: - CompositorRepresentable + +struct CompositorRepresentable: NSViewRepresentable { + @Binding var text: String + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.drawsBackground = true + scrollView.backgroundColor = Theme.current.base + + let compositor = CompositorView(frame: scrollView.contentView.bounds) + compositor.scrollView = scrollView + compositor.autoresizingMask = [.width] + compositor.onContentHeightChanged = { + compositor.frame.size.height = compositor.contentHeight + } + + scrollView.documentView = compositor + + context.coordinator.compositor = compositor + context.coordinator.loadDocument(text) + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + let coord = context.coordinator + if coord.lastSyncedText != text { + coord.loadDocument(text) + } + scrollView.backgroundColor = Theme.current.base + } + + class Coordinator { + var parent: CompositorRepresentable + var compositor: CompositorView? + var lastSyncedText: String = "" + + init(_ parent: CompositorRepresentable) { + self.parent = parent + } + + func loadDocument(_ markdown: String) { + guard let compositor = compositor else { return } + lastSyncedText = markdown + + let descriptors = parseDocument(markdown) + var newBlocks: [CompositorBlock] = [] + + for desc in descriptors { + switch desc.type { + case .text: + let block = TextBlock(text: desc.content) + block.compositor = compositor + newBlocks.append(block) + case .horizontalRule: + newBlocks.append(HRBlock(sourceText: desc.content)) + case .table: + // Stage 3 will add TableBlock; for now treat as text + let block = TextBlock(text: desc.content) + block.compositor = compositor + newBlocks.append(block) + } + } + + compositor.setBlocks(newBlocks) + } + + func syncToBinding() { + guard let compositor = compositor else { return } + let serialized = serializeDocument(compositor.blocks) + lastSyncedText = serialized + parent.text = serialized + } + } +}