Files
lanework/Kanban/UI/Card/CommentTextEditor.swift
T
rzen 9588f7b1f0 Comments, phase 3 — search, the thread find, announcements, and a11y
Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 21:30:22 -04:00

269 lines
13 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
/// The pane's focus register. This surface reports itself as an **authoring** one, which is what
/// makes ⌘F here the editor's ordinary find rather than the thread's (05-card-window.md ▸ Preview:
/// "the composer and an inline comment edit are their own focused text surfaces with the editor's
/// ordinary find"). `nil` in a preview or a test that mounts the editor alone.
var focus: CardComments?
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
// The pane's focus register, and — while this editor holds the keyboard — the find ⌘F runs.
// `performTextFinderAction` takes its verb from the sender's `tag`, which is how the standard
// Edit ▸ Find item drives it (`CardBodySurface`'s own note); the menu item's key equivalent
// fires before this view ever sees ⌘F, so the action has to be reachable from outside.
let focus = focus
textView.onFocusChange = { [weak textView] gained in
guard gained else {
focus?.focusLeft(.authoring)
return
}
focus?.focusEntered(.authoring) { [weak textView] in
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
let sender = NSMenuItem()
sender.tag = NSTextFinder.Action.showFindInterface.rawValue
textView.performTextFinderAction(sender)
}
}
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)?
/// **It says when it has the keyboard** — `CommentBodyTextView`'s pair, and for its reason: ⌘F's
/// route is a focus question, and the responder is the only thing that can answer it. Reported
/// here rather than through `textDidEndEditing` because that fires when the *field editor* ends,
/// which for an uneditable editor (the read-only lock) never happens at all.
var onFocusChange: ((Bool) -> Void)?
override func becomeFirstResponder() -> Bool {
let accepted = super.becomeFirstResponder()
if accepted { onFocusChange?(true) }
return accepted
}
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned { onFocusChange?(false) }
return resigned
}
/// **⌘↩** — "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) }
}
}