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
234 lines
12 KiB
Swift
234 lines
12 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - A rendered comment body
|
|
|
|
/// One comment's Markdown, rendered — **the card-body subset, through the card body's own renderer**
|
|
/// (05-card-window.md ▸ The comments column: "the rendered Markdown body (the card-body subset)").
|
|
///
|
|
/// ### Why the same renderer and not a `Text(AttributedString(markdown:))`
|
|
///
|
|
/// Because "the card-body subset" is a promise about *this* app's subset: fenced code, nested quotes,
|
|
/// GFM tables with per-column alignment, task markers, images resolved against the card's own folder,
|
|
/// HTML shown verbatim as literal code-styled text. `BodyMarkupRenderer` is where every one of those
|
|
/// is decided, and a second renderer here would be a second answer to each — a comment quoting a code
|
|
/// block would look like a different app from the card that carries it.
|
|
///
|
|
/// ### Why it is not `CardBodySurface`
|
|
///
|
|
/// That surface is a hosted **scroll** view, because ⌘F's find bar lives in one and because a card
|
|
/// body is a document. A thread is a *list* of bodies inside one scroller, and a scroll view per row
|
|
/// would be a scroll view that fights its parent — the same reasoning that put the card's title above
|
|
/// the body's scroller rather than inside it. So this is the same TextKit 1 stack with the scroller
|
|
/// taken off and an intrinsic height instead: it lays out at the width it is proposed and reports
|
|
/// exactly the height its text needs.
|
|
///
|
|
/// ### 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
|
|
/// The **card**'s folder, not the comment's — relative images and links in a comment resolve the
|
|
/// same way a card body's do, which is what makes `` 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.
|
|
private static let layoutCeiling: CGFloat = 100_000
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
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()
|
|
let layoutManager = NSLayoutManager()
|
|
storage.addLayoutManager(layoutManager)
|
|
let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
|
container.widthTracksTextView = true
|
|
container.lineFragmentPadding = 0
|
|
layoutManager.addTextContainer(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
|
|
// can be quoted without a mode flip.
|
|
textView.isSelectable = true
|
|
textView.isRichText = true
|
|
textView.drawsBackground = false
|
|
textView.isVerticallyResizable = true
|
|
textView.isHorizontallyResizable = false
|
|
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: CommentBodyTextView, context: Context) {
|
|
let key = Coordinator.RenderKey(
|
|
body: body,
|
|
cardFolder: cardFolder,
|
|
pointSize: CardWindowMetrics.bodyPointSize
|
|
)
|
|
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.
|
|
///
|
|
/// 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: CommentBodyTextView, context: Context) -> CGSize? {
|
|
guard let container = nsView.textContainer, let layoutManager = nsView.layoutManager else {
|
|
return nil
|
|
}
|
|
guard let width = proposal.width, width > 0, width.isFinite else { return nil }
|
|
|
|
// A large finite height rather than `.greatestFiniteMagnitude`: the container tracks the
|
|
// view's width, so the frame is how the width is proposed at all, and an infinite frame
|
|
// height propagates into the layout arithmetic as a value nothing can subtract from.
|
|
nsView.frame = NSRect(x: 0, y: 0, width: width, height: Self.layoutCeiling)
|
|
layoutManager.ensureLayout(for: container)
|
|
return CGSize(width: width, height: layoutManager.usedRect(for: container).height.rounded(.up))
|
|
}
|
|
|
|
// MARK: - Coordinator
|
|
|
|
@MainActor
|
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
|
|
|
struct RenderKey: Equatable {
|
|
let body: String
|
|
let cardFolder: URL?
|
|
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.
|
|
///
|
|
/// **A task marker in a comment is inert.** 05 makes live checkboxes a rule about *Preview*,
|
|
/// the card body's one interactive exception, and gives a comment no toggle write path at all
|
|
/// — the way to change a comment is Edit it. Swallowing the click (rather than letting it fall
|
|
/// through to `NSWorkspace.open`, which would try to open a `kanban-task:` URL) is what keeps
|
|
/// the checkbox drawn-but-dead rather than drawn-and-broken.
|
|
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
|
guard let url = Self.url(from: link) else { return false }
|
|
guard CardBodyLink.parseTask(url) == nil else { return true }
|
|
NSWorkspace.shared.open(url)
|
|
return true
|
|
}
|
|
|
|
private static func url(from link: Any) -> URL? {
|
|
switch link {
|
|
case let url as URL: url
|
|
case let string as String: URL(string: string)
|
|
default: nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|