The card window recomposes into three componentized panes (body, comments, attributes) with two mounts — beside or body-over-comments at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is one persisted app-wide bit, no content-derived auto-show; File ▸ Add Comment flips it on and focuses the composer. The thread renders author lines, edited markers, card-subset Markdown bodies, and read-only Quick Look chips under a count header with the sort- direction control. The composer edits comments/.draft/ on the slow cadence (blur, close, quit, ~30s interval), Escape only moves focus, ⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start bytes, close flushes. File drops within either authoring surface carve out of the window-wide card default into that surface's attachments/; paperclips cover the no-drag path. Close flush runs inline flush, then draft save, then the comments/.trash purge; open sweeps crash residue. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
227 lines
10 KiB
Swift
227 lines
10 KiB
Swift
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<NSPasteboard.PasteboardType> = [
|
|
.fileURL,
|
|
NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
|
]
|
|
return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
|
|
}
|
|
}
|