add block compositor foundation: protocol, view, text block, document model

This commit is contained in:
jess 2026-04-07 07:18:33 -07:00
parent de350b9d45
commit b6a007490e
1 changed files with 434 additions and 0 deletions

434
src/CompositorView.swift Normal file
View File

@ -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..<blocks.count {
let block = blocks[i]
block.layoutBlock(width: width)
block.view.frame = NSRect(x: 0, y: y, width: width, height: block.blockHeight)
y += block.blockHeight
}
let totalHeight = y
if frame.height != totalHeight {
frame.size.height = totalHeight
onContentHeightChanged?()
}
}
func blockDidResize(_ block: CompositorBlock) {
guard let idx = blocks.firstIndex(where: { $0 === block }) else { return }
layoutBlocks(from: idx)
}
override func resizeSubviews(withOldSize oldSize: NSSize) {
super.resizeSubviews(withOldSize: oldSize)
if oldSize.width != bounds.width {
layoutAllBlocks()
}
}
// MARK: - Focus Routing
func activateBlock(at index: Int) {
guard index >= 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
}
}
}