import AppKit import SwiftUI // MARK: - CommentTextEditor /// The text surface both **authoring** surfaces use: the composer, and an inline comment edit /// session (05-card-window.md ▸ The comments column). /// /// ### One editor for both, because they are one thing twice /// /// 05 describes the composer as "an always-visible text area ('Add a comment…', Edit-mode Markdown /// highlighting)" and the inline session as "a body-edit session in miniature". Both are raw Markdown /// with the body editor's highlighting over it, both end on ⌘↩, both answer Escape, and both are /// where a dropped file lands for their own folder. What differs is entirely outside this view — /// which buffer the keystrokes go to, what ⌘↩ means, what Escape means — so all four arrive as /// closures and none of them is decided here. /// /// ### The highlighting is the body editor's, exactly /// /// `MarkdownHighlighter` emits ranges and never a string, so "the text is the raw Markdown, character /// for character — no hidden transforms, no smart substitutions" (05 ▸ Edit) holds here for free, and /// the same smart-substitution deregistrations are repeated below because a comment is as much the /// user's file as a body is. /// /// ### It declines file drags, like the body editor /// /// `acceptableDragTypes` drops the file types so AppKit's hit-test walks past the text view — which /// is what lets the SwiftUI drop target *around* this view take the drop (the composer's carve-out, /// `CommentDropCarveOut`) instead of `NSTextView` inserting a path into the user's Markdown. The /// mechanism is `CardBodyTextView`'s, verbatim; only the target above it differs. struct CommentTextEditor: NSViewRepresentable { let text: String let isEditable: Bool /// Every keystroke — straight into the session, which owns the cadence. let onEdit: (String) -> Void /// ⌘↩ — Post for the composer, Save for an inline session (11-command-nexus.md's grammar table). let onCommandReturn: () -> Void /// Escape — "focus moves out, draft untouched" for the composer; Cancel for an inline session. let onEscape: () -> Void /// Focus left. The composer's first cadence moment ("composer blur"); nothing for an inline /// session, whose commit points are its two buttons. var onBlur: () -> Void = {} /// Bumped to ask for the keyboard — File ▸ Add Comment's second half, and an inline session /// opening. A counter rather than a flag: two requests in a row are two requests. var focusRequest: Int = 0 func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSScrollView { let storage = NSTextStorage() let layoutManager = NSLayoutManager() storage.addLayoutManager(layoutManager) let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) container.widthTracksTextView = true layoutManager.addTextContainer(container) let textView = CommentEditorTextView(frame: .zero, textContainer: container) textView.delegate = context.coordinator textView.isEditable = isEditable textView.isSelectable = true textView.isRichText = false textView.drawsBackground = false textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.autoresizingMask = NSView.AutoresizingMask.width textView.minSize = CGSize(width: 0, height: 0) textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) // The body editor's list, and normative here for its reason: a smart quote substituted into a // fenced code block would be the app silently corrupting the user's file. textView.isAutomaticLinkDetectionEnabled = false textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false textView.isAutomaticDataDetectionEnabled = false textView.smartInsertDeleteEnabled = false textView.allowsUndo = true textView.usesFindBar = true textView.isIncrementalSearchingEnabled = true let padding = CardWindowMetrics.previewPadding(bodyPointSize: CardWindowMetrics.bodyPointSize) textView.textContainerInset = CGSize(width: padding, height: padding) textView.onCommandReturn = onCommandReturn textView.onEscape = onEscape let scrollView = NSScrollView() scrollView.documentView = textView scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.drawsBackground = false scrollView.findBarPosition = .aboveContent context.coordinator.textView = textView context.coordinator.onEdit = onEdit context.coordinator.onBlur = onBlur return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { let coordinator = context.coordinator coordinator.onEdit = onEdit coordinator.onBlur = onBlur guard let textView = scrollView.documentView as? CommentEditorTextView else { return } textView.isEditable = isEditable textView.onCommandReturn = onCommandReturn textView.onEscape = onEscape coordinator.show(text, in: textView, pointSize: CardWindowMetrics.bodyPointSize) guard focusRequest != coordinator.servedFocusRequest else { return } coordinator.servedFocusRequest = focusRequest guard focusRequest > 0 else { return } // Deferred a turn: this runs inside a SwiftUI update, and making a view first responder // re-enters AppKit's responder machinery (`CardBodySurface.Coordinator.enter`'s rule). Task { @MainActor [weak textView] in guard let textView else { return } textView.window?.makeFirstResponder(textView) } } // MARK: - Coordinator @MainActor final class Coordinator: NSObject, NSTextViewDelegate { weak var textView: NSTextView? var onEdit: ((String) -> Void)? var onBlur: (() -> Void)? var servedFocusRequest = 0 /// Set while this coordinator is replacing the view's text, so the resulting change /// notification is not mistaken for typing. private var isSettingText = false /// `CardBodySurface.Coordinator.show(_:in:pointSize:)`, unchanged and for its reason: the /// equality guard is load-bearing rather than an optimization, because this runs on every /// keystroke and replacing the storage with the string it already holds would collapse the /// selection and throw away the undo stack on every character typed. func show(_ text: String, in textView: NSTextView, pointSize: CGFloat) { guard let storage = textView.textStorage else { return } if storage.string != text { let selected = textView.selectedRange() isSettingText = true storage.setAttributedString(NSAttributedString( string: text, attributes: MarkdownHighlighter.baseAttributes(pointSize: pointSize) )) isSettingText = false let length = (text as NSString).length textView.setSelectedRange(NSRange( location: min(selected.location, length), length: min(selected.length, max(0, length - min(selected.location, length))) )) } MarkdownHighlighter.highlight(storage, pointSize: pointSize) textView.typingAttributes = MarkdownHighlighter.baseAttributes(pointSize: pointSize) } func textDidChange(_ notification: Notification) { guard !isSettingText, let textView = notification.object as? NSTextView else { return } onEdit?(textView.string) } /// **Composer blur is a save** (05 ▸ The comments column, the first of the four cadence /// moments). For an inline session `onBlur` is empty: its commit points are Save and Cancel, /// and clicking away from it is neither. func textDidEndEditing(_ notification: Notification) { onBlur?() } } } // MARK: - The editor's text view /// The authoring editor's text view, subclassed for the two keys 11-command-nexus.md's grammar table /// gives it and for the drag types it must not take. final class CommentEditorTextView: NSTextView { var onCommandReturn: (() -> Void)? var onEscape: (() -> Void)? /// **⌘↩** — "Post the draft … / end the edit session at its commit point" /// (11-command-nexus.md ▸ Fixed grammar keys). Intercepted before `super`, which would otherwise /// insert a newline: the chord is the gesture, not a decorated Return. override func keyDown(with event: NSEvent) { let isReturn = event.keyCode == 36 || event.keyCode == 76 let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.function, .numericPad, .capsLock]) if isReturn, modifiers == .command, let onCommandReturn { onCommandReturn() return } super.keyDown(with: event) } /// **Escape.** What it *means* is the caller's — focus out for the composer (never a discard), /// Cancel for an inline session — which is why this only forwards. Intercepted before /// `NSTextView`'s own meaning for it (text completion); with the find bar up the bar is first /// responder and never reaches this. override func cancelOperation(_ sender: Any?) { guard let onEscape else { super.cancelOperation(sender) return } onEscape() } /// **A file drop is never the editor's** — `CardBodyTextView`'s deregistration, here so the drop /// falls through to the authoring surface's own target and lands in *this* surface's /// `attachments/` (05 ▸ Attachments, the hover-target carve-out). override var acceptableDragTypes: [NSPasteboard.PasteboardType] { let fileTypes: Set = [ .fileURL, NSPasteboard.PasteboardType("NSFilenamesPboardType") ] return super.acceptableDragTypes.filter { !fileTypes.contains($0) } } }