import AppKit import SwiftUI /// A SwiftUI wrapper around NSTextView for the editor pane. /// Uses NSViewRepresentable to bridge AppKit's NSTextView into SwiftUI, /// providing line-level control, scroll position access, and performance /// that SwiftUI's TextEditor cannot match. struct EditorTextView: NSViewRepresentable { @Binding var text: String @Binding var scrollOffset: CGFloat var font: NSFont func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.borderType = .noBorder scrollView.drawsBackground = false let textView = NSTextView() textView.isEditable = true textView.isSelectable = true textView.allowsUndo = true textView.isRichText = false textView.usesFontPanel = false textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false // Disable line wrapping — horizontal scroll instead textView.isHorizontallyResizable = true textView.textContainer?.widthTracksTextView = false textView.textContainer?.containerSize = NSSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude ) textView.maxSize = NSSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude ) // Configure font and text color textView.font = font textView.textColor = .textColor textView.backgroundColor = .clear textView.drawsBackground = false textView.insertionPointColor = .textColor // Set the text textView.string = text // Configure the text container for consistent line spacing textView.textContainer?.lineFragmentPadding = 4 textView.textContainerInset = NSSize(width: 8, height: 8) scrollView.documentView = textView context.coordinator.textView = textView context.coordinator.scrollView = scrollView // Observe text changes textView.delegate = context.coordinator // Observe scroll changes scrollView.contentView.postsBoundsChangedNotifications = true NotificationCenter.default.addObserver( context.coordinator, selector: #selector(Coordinator.scrollViewDidScroll(_:)), name: NSView.boundsDidChangeNotification, object: scrollView.contentView ) return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? NSTextView else { return } // Update font if changed if textView.font != font { textView.font = font // Reapply font to entire text storage let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0) textView.textStorage?.addAttribute(.font, value: font, range: range) } // Update text only if it actually changed (avoid feedback loops) if textView.string != text { let selectedRanges = textView.selectedRanges textView.string = text textView.selectedRanges = selectedRanges } // Update scroll position if driven externally context.coordinator.isUpdatingScroll = true let currentOffset = scrollView.contentView.bounds.origin.y if abs(currentOffset - scrollOffset) > 0.5 { scrollView.contentView.scroll(to: NSPoint(x: 0, y: scrollOffset)) scrollView.reflectScrolledClipView(scrollView.contentView) } context.coordinator.isUpdatingScroll = false } final class Coordinator: NSObject, NSTextViewDelegate { var parent: EditorTextView weak var textView: NSTextView? weak var scrollView: NSScrollView? var isUpdatingScroll = false init(_ parent: EditorTextView) { self.parent = parent } func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } parent.text = textView.string } @objc func scrollViewDidScroll(_ notification: Notification) { guard !isUpdatingScroll, let scrollView = scrollView else { return } let offset = scrollView.contentView.bounds.origin.y DispatchQueue.main.async { [weak self] in self?.parent.scrollOffset = offset } } deinit { NotificationCenter.default.removeObserver(self) } } }