diff --git a/src/CompositorView.swift b/src/CompositorView.swift index 9f34163..b5167d3 100644 --- a/src/CompositorView.swift +++ b/src/CompositorView.swift @@ -353,82 +353,573 @@ class HRBlock: NSObject, CompositorBlock { struct CompositorRepresentable: NSViewRepresentable { @Binding var text: String + var evalResults: [Int: EvalEntry] + var fileFormat: FileFormat + var onEvaluate: () -> Void + var onBackspaceAtStart: (() -> Void)? 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 scrollView = NSTextView.scrollableTextView() + let defaultTV = scrollView.documentView as! NSTextView - let compositor = CompositorView(frame: scrollView.contentView.bounds) - compositor.scrollView = scrollView - compositor.autoresizingMask = [.width] - compositor.onContentHeightChanged = { - compositor.frame.size.height = compositor.contentHeight + let tc = defaultTV.textContainer! + tc.replaceLayoutManager(MarkdownLayoutManager()) + + let textView = LineNumberTextView(frame: defaultTV.frame, textContainer: tc) + textView.minSize = defaultTV.minSize + textView.maxSize = defaultTV.maxSize + textView.isVerticallyResizable = defaultTV.isVerticallyResizable + textView.isHorizontallyResizable = defaultTV.isHorizontallyResizable + textView.autoresizingMask = defaultTV.autoresizingMask + + textView.isEditable = true + textView.isSelectable = true + textView.allowsUndo = true + textView.isRichText = true + textView.usesFindBar = true + textView.isIncrementalSearchingEnabled = true + textView.font = Theme.editorFont + textView.textColor = Theme.current.text + textView.backgroundColor = Theme.current.base + textView.insertionPointColor = Theme.current.text + textView.selectedTextAttributes = [ + .backgroundColor: Theme.current.surface1 + ] + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.smartInsertDeleteEnabled = false + textView.isAutomaticLinkDetectionEnabled = false + + textView.textContainerInset = NSSize(width: 4, height: 8) + textView.textContainer?.widthTracksTextView = false + textView.registerForDraggedTypes([.fileURL]) + + scrollView.documentView = textView + + textView.string = text + textView.evalResults = evalResults + textView.delegate = context.coordinator + context.coordinator.textView = textView + + if let ts = textView.textStorage { + ts.beginEditing() + applySyntaxHighlighting(to: ts, format: fileFormat) + ts.endEditing() } + textView.applyEvalSpacing() + textView.typingAttributes = [ + .font: Theme.editorFont, + .foregroundColor: Theme.current.text + ] + updateBlockRanges(for: textView) - scrollView.documentView = compositor - - context.coordinator.compositor = compositor - context.coordinator.loadDocument(text) + DispatchQueue.main.async { + context.coordinator.triggerImageUpdate() + context.coordinator.triggerTableUpdate() + } return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { - let coord = context.coordinator - if coord.lastSyncedText != text { - coord.loadDocument(text) + guard let textView = scrollView.documentView as? LineNumberTextView else { return } + if textView.string != text { + let selectedRanges = textView.selectedRanges + textView.string = text + if let ts = textView.textStorage { + ts.beginEditing() + applySyntaxHighlighting(to: ts, format: fileFormat) + ts.endEditing() + } + textView.selectedRanges = selectedRanges + updateBlockRanges(for: textView) } - scrollView.backgroundColor = Theme.current.base + textView.backgroundColor = Theme.current.base + textView.insertionPointColor = Theme.current.text + textView.typingAttributes = [ + .font: Theme.editorFont, + .foregroundColor: Theme.current.text + ] + + textView.evalResults = evalResults + textView.needsDisplay = true } - class Coordinator { + class Coordinator: NSObject, NSTextViewDelegate { var parent: CompositorRepresentable - var compositor: CompositorView? - var lastSyncedText: String = "" + weak var textView: NSTextView? + private var isUpdatingImages = false + private var isUpdatingTables = false + private var embeddedTableViews: [MarkdownTableView] = [] + private var observers: [NSObjectProtocol] = [] init(_ parent: CompositorRepresentable) { self.parent = parent + super.init() + + observers.append(NotificationCenter.default.addObserver( + forName: .focusEditor, object: nil, queue: .main + ) { [weak self] _ in + guard let tv = self?.textView else { return } + tv.window?.makeFirstResponder(tv) + tv.setSelectedRange(NSRange(location: 0, length: 0)) + }) + observers.append(NotificationCenter.default.addObserver( + forName: .formatDocument, object: nil, queue: .main + ) { [weak self] _ in + self?.formatCurrentDocument() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .settingsChanged, object: nil, queue: .main + ) { [weak self] _ in + guard let tv = self?.textView, let ts = tv.textStorage else { return } + let palette = Theme.current + tv.backgroundColor = palette.base + tv.insertionPointColor = palette.text + tv.selectedTextAttributes = [.backgroundColor: palette.surface1] + tv.typingAttributes = [ + .font: Theme.editorFont, + .foregroundColor: palette.text + ] + ts.beginEditing() + applySyntaxHighlighting(to: ts, format: self?.parent.fileFormat ?? .markdown) + ts.endEditing() + (tv as? LineNumberTextView)?.applyEvalSpacing() + tv.needsDisplay = true + }) + observers.append(NotificationCenter.default.addObserver( + forName: .boldSelection, object: nil, queue: .main + ) { [weak self] _ in + self?.wrapSelection(with: "**") + }) + observers.append(NotificationCenter.default.addObserver( + forName: .italicizeSelection, object: nil, queue: .main + ) { [weak self] _ in + self?.wrapSelection(with: "*") + }) + observers.append(NotificationCenter.default.addObserver( + forName: .insertTable, object: nil, queue: .main + ) { [weak self] _ in + self?.insertBlankTable() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .smartEval, object: nil, queue: .main + ) { [weak self] _ in + self?.performSmartEval() + }) } - func loadDocument(_ markdown: String) { - guard let compositor = compositor else { return } - lastSyncedText = markdown + deinit { + for obs in observers { + NotificationCenter.default.removeObserver(obs) + } + } - let descriptors = parseDocument(markdown) - var newBlocks: [CompositorBlock] = [] + func textDidChange(_ notification: Notification) { + guard let tv = textView, let ts = tv.textStorage else { return } + if isUpdatingImages || isUpdatingTables { return } + parent.text = tv.string + let sel = tv.selectedRanges + ts.beginEditing() + applySyntaxHighlighting(to: ts, format: parent.fileFormat) + ts.endEditing() + (tv as? LineNumberTextView)?.applyEvalSpacing() + tv.typingAttributes = [ + .font: Theme.editorFont, + .foregroundColor: Theme.current.text + ] + tv.selectedRanges = sel + updateBlockRanges(for: tv) + tv.needsDisplay = true - 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) + DispatchQueue.main.async { [weak self] in + self?.updateInlineImages() + self?.updateEmbeddedTables() + } + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + insertNewlineWithAutoIndent(textView) + DispatchQueue.main.async { [weak self] in + self?.parent.onEvaluate() + } + return true + } + if commandSelector == #selector(NSResponder.deleteBackward(_:)) { + if textView.selectedRange().location == 0 && textView.selectedRange().length == 0 { + parent.onBackspaceAtStart?() + return true + } + } + return false + } + + func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString text: String?) -> Bool { + guard let text = text, text.count == 1 else { return true } + let ch = text.first! + let hasSelection = range.length > 0 + + if !hasSelection { + let closerChars: Set = ["}", ")", "]", "\"", "'"] + if closerChars.contains(ch) { + let str = textView.string as NSString + if range.location < str.length { + let next = Character(UnicodeScalar(str.character(at: range.location))!) + if next == ch { + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + return false + } + } } } - compositor.setBlocks(newBlocks) + let pairClosers: [Character: Character] = ["{": "}", "(": ")", "[": "]"] + if let close = pairClosers[ch] { + if hasSelection { + let selected = (textView.string as NSString).substring(with: range) + textView.insertText(String(ch) + selected + String(close), replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count)) + } else { + textView.insertText(String(ch) + String(close), replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + } + return false + } + + if ch == "\"" || ch == "'" { + if hasSelection { + let selected = (textView.string as NSString).substring(with: range) + textView.insertText(String(ch) + selected + String(ch), replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count)) + } else { + textView.insertText(String(ch) + String(ch), replacementRange: range) + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + } + return false + } + + return true } - func syncToBinding() { - guard let compositor = compositor else { return } - let serialized = serializeDocument(compositor.blocks) - lastSyncedText = serialized - parent.text = serialized + func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { + var urlString: String? + if let url = link as? URL { + urlString = url.absoluteString + } else if let str = link as? String { + urlString = str + } + guard let str = urlString, let url = URL(string: str) else { return false } + NSWorkspace.shared.open(url) + return true + } + + // MARK: - Editing helpers + + private func formatCurrentDocument() { + guard let tv = textView else { return } + let format = parent.fileFormat + let text = tv.string + + var formatted: String? + switch format { + case .json: + formatted = formatJSON(text) + default: + if format.isCode { + formatted = normalizeIndentation(text) + } + } + + if let result = formatted, result != text { + let sel = tv.selectedRanges + tv.string = result + parent.text = result + if let ts = tv.textStorage { + ts.beginEditing() + applySyntaxHighlighting(to: ts, format: format) + ts.endEditing() + } + (tv as? LineNumberTextView)?.applyEvalSpacing() + tv.selectedRanges = sel + tv.needsDisplay = true + } + } + + private func formatJSON(_ text: String) -> String? { + guard let data = text.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data), + let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]), + let result = String(data: pretty, encoding: .utf8) else { return nil } + return result + } + + private func normalizeIndentation(_ text: String) -> String { + let lines = text.components(separatedBy: "\n") + var result: [String] = [] + var depth = 0 + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + result.append("") + continue + } + + let opens = trimmed.filter { "{([".contains($0) }.count + let closes = trimmed.filter { "})]".contains($0) }.count + let delta = opens - closes + + if delta < 0 { depth = max(0, depth + delta) } + let indent = String(repeating: " ", count: depth) + result.append(indent + trimmed) + if delta > 0 { depth += delta } + } + + return result.joined(separator: "\n") + } + + private func insertNewlineWithAutoIndent(_ textView: NSTextView) { + let str = textView.string as NSString + let cursor = textView.selectedRange().location + let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0)) + let currentLine = str.substring(with: lineRange) + + var indent = "" + for c in currentLine { + if c == " " || c == "\t" { indent.append(c) } + else { break } + } + + let trimmed = currentLine.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldIndent = trimmed.hasSuffix("{") || trimmed.hasSuffix(":") || + trimmed.hasSuffix("do") || trimmed.hasSuffix("then") || + trimmed.hasSuffix("(") || trimmed.hasSuffix("[") + + if shouldIndent { + indent += " " + } + + let charBeforeCursor: Character? = cursor > 0 ? Character(UnicodeScalar(str.character(at: cursor - 1))!) : nil + let charAtCursor: Character? = cursor < str.length ? Character(UnicodeScalar(str.character(at: cursor))!) : nil + + if let before = charBeforeCursor, let after = charAtCursor, + (before == "{" && after == "}") || (before == "(" && after == ")") || (before == "[" && after == "]") { + let baseIndent = indent.count >= 4 ? String(indent.dropLast(4)) : "" + let insertion = "\n" + indent + "\n" + baseIndent + textView.insertText(insertion, replacementRange: textView.selectedRange()) + textView.setSelectedRange(NSRange(location: cursor + 1 + indent.count, length: 0)) + return + } + + textView.insertText("\n" + indent, replacementRange: textView.selectedRange()) + } + + private func wrapSelection(with wrapper: String) { + guard let tv = textView else { return } + let sel = tv.selectedRange() + guard sel.length > 0 else { return } + let str = tv.string as NSString + let selected = str.substring(with: sel) + let wrapped = wrapper + selected + wrapper + tv.insertText(wrapped, replacementRange: sel) + tv.setSelectedRange(NSRange(location: sel.location + wrapper.count, length: selected.count)) + } + + private func insertBlankTable() { + guard let tv = textView else { return } + let table = "| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| | | |\n" + tv.insertText(table, replacementRange: tv.selectedRange()) + } + + private func performSmartEval() { + guard let tv = textView else { return } + let str = tv.string as NSString + let cursor = tv.selectedRange().location + let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0)) + let line = str.substring(with: lineRange).trimmingCharacters(in: .newlines) + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("let ") { + if let eqIdx = trimmed.firstIndex(of: "="), eqIdx > trimmed.startIndex { + let afterLet = trimmed[trimmed.index(trimmed.startIndex, offsetBy: 4).. maxWidth ? maxWidth / image.size.width : 1.0 + let displaySize = NSSize( + width: image.size.width * ratio, + height: image.size.height * ratio + ) + image.size = displaySize + + let attachment = NSTextAttachment() + let cell = NSTextAttachmentCell(imageCell: image) + attachment.attachmentCell = cell + + let attachStr = NSMutableAttributedString(string: "\n") + attachStr.append(NSAttributedString(attachment: attachment)) + attachStr.addAttribute(.toolTip, value: resolved, range: NSRange(location: 1, length: 1)) + + let lineEnd = NSMaxRange(match.range) + offset + let insertAt = min(lineEnd, ts.length) + ts.insert(attachStr, at: insertAt) + offset += attachStr.length + } + + ts.endEditing() + isUpdatingImages = false + } + + private func findExistingImageAttachments(in textStorage: NSTextStorage) -> [(NSRange, String)] { + var results: [(NSRange, String)] = [] + let fullRange = NSRange(location: 0, length: textStorage.length) + textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in + if value is NSTextAttachment { + var extRange = range + if extRange.location > 0 { + let prev = NSRange(location: extRange.location - 1, length: 1) + let ch = (textStorage.string as NSString).substring(with: prev) + if ch == "\n" { + extRange = NSRange(location: prev.location, length: extRange.length + 1) + } + } + let tip = textStorage.attribute(.toolTip, at: range.location, effectiveRange: nil) as? String ?? "" + results.append((extRange, tip)) + } + } + return results + } + + // MARK: - Embedded tables + + private func updateEmbeddedTables() { + guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager, + let tc = tv.textContainer else { return } + + for tableView in embeddedTableViews { + tableView.removeFromSuperview() + } + embeddedTableViews.removeAll() + + let origin = tv.textContainerOrigin + let text = tv.string as NSString + for block in lm.blockRanges { + guard case .tableBlock = block.kind else { continue } + guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue } + + let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil) + let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc) + + let tableX = origin.x + 4 + let tableY = rect.origin.y + origin.y + let tableWidth = tc.containerSize.width - 8 + + let tableView = MarkdownTableView(table: parsed, width: tableWidth) + tableView.frame.origin = NSPoint(x: tableX, y: tableY) + tableView.textView = tv + + tableView.onTableChanged = { [weak self] updatedTable in + self?.applyTableEdit(updatedTable) + } + + tv.addSubview(tableView) + embeddedTableViews.append(tableView) + } + } + + private func applyTableEdit(_ table: ParsedTable) { + guard let tv = textView, let ts = tv.textStorage else { return } + let newMarkdown = rebuildTableMarkdown(table) + let range = table.sourceRange + guard NSMaxRange(range) <= ts.length else { return } + + isUpdatingTables = true + let sel = tv.selectedRanges + ts.beginEditing() + ts.replaceCharacters(in: range, with: newMarkdown) + applySyntaxHighlighting(to: ts, format: parent.fileFormat) + ts.endEditing() + (tv as? LineNumberTextView)?.applyEvalSpacing() + tv.selectedRanges = sel + parent.text = tv.string + updateBlockRanges(for: tv) + isUpdatingTables = false } } } diff --git a/src/EditorView.swift b/src/EditorView.swift index 25a24a8..094a830 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -1084,7 +1084,7 @@ struct EditorView: View { } var body: some View { - EditorTextView( + CompositorRepresentable( text: bodyBinding, evalResults: offsetEvalResults(state.evalResults), fileFormat: state.currentFileFormat, @@ -2683,14 +2683,14 @@ private func rangeOverlapsAny(_ range: NSRange, ranges: [NSRange]) -> Bool { // MARK: - Image Cache & Path Resolution -private let imageCacheDir: URL = { +let imageCacheDir: URL = { let dir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".swiftly/images", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir }() -private func resolveLocalImagePath(_ rawPath: String) -> String? { +func resolveLocalImagePath(_ rawPath: String) -> String? { if rawPath.hasPrefix("http://") || rawPath.hasPrefix("https://") { return nil } let expanded: String if rawPath.hasPrefix("~/") {