Files
lanework/Kanban/UI/Card/CommentTextEditor.swift
T
rzen fe3ffac48e Comments, phase 2 — the pane, the composer, and the inline session
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
2026-07-30 20:19:52 -04:00

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) }
}
}