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
This commit is contained in:
2026-07-30 21:30:22 -04:00
parent fe3ffac48e
commit 9588f7b1f0
31 changed files with 3036 additions and 73 deletions
+105 -12
View File
@@ -23,8 +23,15 @@ import SwiftUI
/// taken off and an intrinsic height instead: it lays out at the width it is proposed and reports
/// exactly the height its text needs.
///
/// The pane's find-in-text over the whole rendered thread is phase 3's (05 Preview scopes F to
/// "the comments pane, where it searches the whole rendered thread"); nothing here forecloses it.
/// ### It is a find *result* surface, not a find client
///
/// 05 scopes F over the pane to "the whole rendered thread", which no per-row `NSTextFinder` could
/// span (`CommentThreadFind` explains the mechanism chosen instead). What this view owes that
/// mechanism is two things: the storage it draws must be the same text the search ran over it is,
/// because both come from `BodyMarkupRenderer` over the same body and it must be able to draw a
/// highlight over a range without touching that storage. `NSLayoutManager`'s **temporary attributes**
/// are exactly that: a display-only overlay, discarded and reapplied freely, which cannot end up in
/// anything the user copies out.
struct CommentBodyView: NSViewRepresentable {
let body: String
@@ -32,6 +39,14 @@ struct CommentBodyView: NSViewRepresentable {
/// same way a card body's do, which is what makes `![](attachments/shot.png)` mean one thing in
/// this window (05 Preview).
let cardFolder: URL?
/// This comment's find hits, in the rendered text's coordinates (`CommentThreadFind.matches(in:)`).
var highlights: [NSRange] = []
/// Which of them is the current one, if it is in this comment drawn in the stronger colour, the
/// find bar's own convention.
var currentHighlight: NSRange?
/// The pane's focus register this surface reports itself as a *reading* surface, which is what
/// makes F over a clicked-into comment mean the thread (`CardComments.paneFocus`).
var focus: CardComments?
/// The height a measurement pass lays out into tall enough that no comment reaches it, finite
/// so the arithmetic stays well-defined.
@@ -41,7 +56,7 @@ struct CommentBodyView: NSViewRepresentable {
Coordinator()
}
func makeNSView(context: Context) -> NSTextView {
func makeNSView(context: Context) -> CommentBodyTextView {
// TextKit 1, explicitly, for `CardBodySurface`'s reason: `NSTextTable` the browser sizing
// rule GFM tables are laid out by does not lay out in TextKit 2.
let storage = NSTextStorage()
@@ -52,7 +67,7 @@ struct CommentBodyView: NSViewRepresentable {
container.lineFragmentPadding = 0
layoutManager.addTextContainer(container)
let textView = NSTextView(frame: .zero, textContainer: container)
let textView = CommentBodyTextView(frame: .zero, textContainer: container)
textView.delegate = context.coordinator
textView.isEditable = false
// Selectable and copyable, the whole thread Preview's own posture, and the reason a comment
@@ -65,21 +80,64 @@ struct CommentBodyView: NSViewRepresentable {
textView.textContainerInset = .zero
textView.linkTextAttributes = [.cursor: NSCursor.pointingHand]
textView.displaysLinkToolTips = true
// The pane's focus register, so F over a comment the user has clicked into means the thread
// (`CardComments.paneFocus`). A weak capture is not needed: the handle outlives the window.
let focus = focus
textView.onFocusChange = { gained in
if gained {
focus?.focusEntered(.thread)
} else {
focus?.focusLeft(.thread)
}
}
return textView
}
func updateNSView(_ textView: NSTextView, context: Context) {
func updateNSView(_ textView: CommentBodyTextView, context: Context) {
let key = Coordinator.RenderKey(
body: body,
cardFolder: cardFolder,
pointSize: CardWindowMetrics.bodyPointSize
)
guard context.coordinator.rendered != key else { return }
context.coordinator.rendered = key
textView.textStorage?.setAttributedString(BodyMarkupRenderer.attributedString(
for: BodyMarkup.parse(body),
context: BodyMarkupRenderer.Context(pointSize: key.pointSize, cardFolder: cardFolder)
))
if context.coordinator.rendered != key {
context.coordinator.rendered = key
textView.textStorage?.setAttributedString(BodyMarkupRenderer.attributedString(
for: BodyMarkup.parse(body),
context: BodyMarkupRenderer.Context(pointSize: key.pointSize, cardFolder: cardFolder)
))
// The overlay describes ranges in text that has just been replaced, so it is reapplied
// rather than assumed to have survived.
context.coordinator.highlighted = nil
}
let highlightKey = Coordinator.HighlightKey(ranges: highlights, current: currentHighlight)
guard context.coordinator.highlighted != highlightKey else { return }
context.coordinator.highlighted = highlightKey
Self.applyHighlights(highlightKey, in: textView)
}
/// Draws the find's hits **temporary attributes only**, so nothing here can reach the text
/// storage a reader copies out of, and clearing is a single call rather than a diff.
///
/// The two colours are the platform's own find vocabulary: every hit in the secondary selection
/// colour, the current one in the *find* highlight colour, which is what an `NSTextFinder` bar
/// draws and therefore what a user of this app's other finds has already learned.
private static func applyHighlights(_ key: Coordinator.HighlightKey, in textView: CommentBodyTextView) {
guard let layoutManager = textView.layoutManager, let storage = textView.textStorage else { return }
let whole = NSRange(location: 0, length: storage.length)
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: whole)
for range in key.ranges where NSMaxRange(range) <= storage.length {
layoutManager.addTemporaryAttributes(
[.backgroundColor: NSColor.unemphasizedSelectedTextBackgroundColor],
forCharacterRange: range
)
}
guard let current = key.current, NSMaxRange(current) <= storage.length else { return }
layoutManager.addTemporaryAttributes(
[.backgroundColor: NSColor.findHighlightColor],
forCharacterRange: current
)
}
/// **The intrinsic height** the whole reason this is not a scroll view.
@@ -87,7 +145,7 @@ struct CommentBodyView: NSViewRepresentable {
/// The container is laid out at the proposed width and asked what it used. `ensureLayout` is not
/// optional: `usedRect` is only meaningful once the glyphs have been laid, and an unlaid container
/// answers a zero-height rect, which would collapse every comment in the thread to nothing.
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextView, context: Context) -> CGSize? {
func sizeThatFits(_ proposal: ProposedViewSize, nsView: CommentBodyTextView, context: Context) -> CGSize? {
guard let container = nsView.textContainer, let layoutManager = nsView.layoutManager else {
return nil
}
@@ -112,7 +170,16 @@ struct CommentBodyView: NSViewRepresentable {
let pointSize: CGFloat
}
/// What the find overlay currently draws kept for `RenderKey`'s reason one layer down: the
/// update runs on every unrelated state change in the window, and reapplying an identical
/// overlay would redraw every visible comment on each of them.
struct HighlightKey: Equatable {
let ranges: [NSRange]
let current: NSRange?
}
var rendered: RenderKey?
var highlighted: HighlightKey?
/// Links behave exactly as they do in a card body: external URLs go to the browser, relative
/// ones already resolved to file URLs by the renderer go to their default app.
@@ -138,3 +205,29 @@ struct CommentBodyView: NSViewRepresentable {
}
}
}
// MARK: - The row's text view
/// A rendered comment's text view, subclassed for one thing: **it says when it has the keyboard**.
///
/// `FocusReportingSearchField`'s shape, and for its reason F's route is a *focus* question
/// (05-card-window.md Preview: the find covers "the focused surface"), and the only responder that
/// can answer it is the one taking and losing first responder. Both halves are overridden here rather
/// than one being inferred from the other, because a reading surface has no editing session and
/// therefore no `textDidEndEditing` to hang the loss on.
final class CommentBodyTextView: NSTextView {
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
}
}