add block compositor foundation: protocol, view, text block, document model
This commit is contained in:
parent
de350b9d45
commit
b6a007490e
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue