import AppKit import SwiftUI // MARK: - StripedTextView /// NSTextView subclass that draws alternating row background stripes. /// Used by both the editor and answer column to provide zebra-striping /// that visually connects lines across panes. final class StripedTextView: NSTextView { /// Subtle stripe color for odd-numbered lines. static let stripeColor = NSColor.secondaryLabelColor.withAlphaComponent(0.04) override func drawBackground(in rect: NSRect) { super.drawBackground(in: rect) guard let layoutManager = layoutManager, let textContainer = textContainer else { return } let stripeColor = Self.stripeColor // We need the absolute line index (not just visible lines) so that // the stripe pattern stays consistent when scrolling and matches // the answer column. Walk all line fragments from the start of the // document, but only fill those that intersect the dirty rect. let fullGlyphRange = layoutManager.glyphRange(for: textContainer) var lineIndex = 0 layoutManager.enumerateLineFragments(forGlyphRange: fullGlyphRange) { fragmentRect, _, _, _, _ in if lineIndex % 2 == 1 { var stripeRect = fragmentRect stripeRect.origin.x = 0 stripeRect.size.width = self.bounds.width stripeRect.origin.y += self.textContainerInset.height // Only draw if the stripe intersects the dirty rect if stripeRect.intersects(rect) { stripeColor.setFill() NSBezierPath.fill(stripeRect) } } lineIndex += 1 } } } // MARK: - EditorTextView /// 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 var alignment: NSTextAlignment = .left 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 = StripedTextView() 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 // Enable line wrapping — tracks text view width textView.isHorizontallyResizable = false textView.textContainer?.widthTracksTextView = true textView.textContainer?.containerSize = NSSize( width: 0, 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 = true textView.insertionPointColor = .textColor // Set alignment textView.alignment = alignment // 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 let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0) textView.textStorage?.addAttribute(.font, value: font, range: range) } // Update alignment if changed if textView.alignment != alignment { textView.alignment = alignment let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = alignment textView.textStorage?.addAttribute(.paragraphStyle, value: paragraphStyle, 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) } } }