Files
lanework/Kanban/UI/Card/CommentThreadFind.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

367 lines
18 KiB
Swift

import AppKit
import Observation
import SwiftUI
// MARK: - Where the card window's ⌘F goes
/// Which of the card window's text surfaces Edit ▸ Find is about right now (05-card-window.md ▸
/// Preview):
///
/// > **Edit ▸ Find (⌘F) is find-in-text here** — the standard find bar over the focused surface
/// > (Preview's selectable text, the Edit editor, raw source — **and the comments pane, where it
/// > searches the whole rendered thread, `.draft` excluded; the composer and an inline comment edit
/// > are their own focused text surfaces with the editor's ordinary find**).
public enum CardWindowFindRoute: Sendable, Equatable {
/// The body column's substrate — Preview, Edit, or the raw-source outlet. `NSTextFinder` over the
/// one hosted scroll view, which is what ⌘F has always meant here (`CardBodyPresentation.findInText`).
case body
/// The comments pane's **whole rendered thread**, across rows (`CommentThreadFind`).
case thread
/// The composer, or an open inline edit session: "their own text surfaces with the editor's
/// ordinary find" — a stock `NSTextView` find bar over that one editor.
case authoring
}
/// Which surface *inside* the comments pane holds the keyboard, as the pane's own views report it.
///
/// Two cases and not three, because the routing only ever asks one question: is this a *reading*
/// surface (a rendered comment, where find means the thread) or an *authoring* one (the composer or an
/// inline session, where find means that editor). Which comment is being edited is the session's own
/// business and no concern of ⌘F's.
public enum CommentPaneFocus: Sendable, Equatable {
case thread
case authoring
}
/// **The routing rule, as a pure function** — extracted for every menu-validation rule's reason in
/// this codebase: a menu item's dispatch is otherwise only observable by driving the menu bar, and
/// "⌘F follows focus" is exactly the kind of clause that regresses into "⌘F always means the body".
public enum CardWindowFind {
/// - Parameters:
/// - paneFocus: what the comments pane last reported (`CardComments.paneFocus`).
/// - isThreadFindShowing: whether the thread's find bar is already up. **It outranks an absent
/// pane focus**, because raising the bar takes the keyboard *out* of the thread and into the
/// bar's own field — so a second ⌘F, which every user expects to return to the search field,
/// would otherwise fall through to the body.
/// - hasBody: whether a body surface exists to find in at all (`CardBodyPresentation.findInText`).
/// - Returns: the route, or `nil` when this window has nothing to find in — which is also the
/// window-less case, where Edit ▸ Find is the board's.
public static func route(
paneFocus: CommentPaneFocus?,
isThreadFindShowing: Bool,
hasBody: Bool
) -> CardWindowFindRoute? {
if paneFocus == .authoring { return .authoring }
if paneFocus == .thread || isThreadFindShowing { return .thread }
return hasBody ? .body : nil
}
}
// MARK: - The thread's rows, and what a query finds in them
/// One comment as the find sees it: an identity, and the **rendered** text of its body.
///
/// Rendered rather than raw Markdown, and that is the whole reason this type exists. 05 scopes the
/// find to "the whole *rendered* thread", the ranges it produces have to land in the text views the
/// rows actually draw (`CommentBodyView`, whose storage is the same renderer's output), and a user
/// searching for `code` should not match the backticks around it.
public struct CommentThreadRow: Sendable, Equatable {
public let id: ItemID
public let text: String
public init(id: ItemID, text: String) {
self.id = id
self.text = text
}
}
/// One hit: which comment, and where in its rendered text.
public struct CommentThreadMatch: Sendable, Equatable {
public let comment: ItemID
public let range: NSRange
public init(comment: ItemID, range: NSRange) {
self.comment = comment
self.range = range
}
}
/// **Finding a query in a thread** — pure, so the whole of what ⌘F *means* over the comments pane is
/// checkable without a window (05-card-window.md ▸ Preview).
public enum CommentThreadSearch {
/// Every match, in reading order: rows in the order given, hits within a row left to right.
///
/// ### The comparison is the board search's, not `NSTextFinder`'s default
///
/// Case- **and diacritic-insensitive**, which is the rule 04-interactions.md fixes for the board's
/// query and the one this app has therefore taught its users. It is done with
/// `NSString.range(of:options:range:)` rather than by folding both sides, because folding can
/// change a string's length — a decomposed character folds to fewer UTF-16 units — and a range
/// measured in the folded string would highlight the wrong characters in the real one.
///
/// An **empty query finds nothing** (rather than everything): the find bar opens empty, and a bar
/// that reported "1 of 4000" before a key was pressed would be noise.
public static func matches(in rows: [CommentThreadRow], query: String) -> [CommentThreadMatch] {
guard !query.isEmpty else { return [] }
var matches: [CommentThreadMatch] = []
for row in rows {
let text = row.text as NSString
var searched = NSRange(location: 0, length: text.length)
while searched.length > 0 {
let found = text.range(
of: query,
options: [.caseInsensitive, .diacriticInsensitive],
range: searched
)
guard found.location != NSNotFound else { break }
matches.append(CommentThreadMatch(comment: row.id, range: found))
// Advance past the whole hit, so overlapping occurrences ("aa" in "aaa") are **one**
// match — which is what `NSTextFinder` does, and therefore what the body's ⌘F in this
// same window already does. `max(1, …)` keeps the loop advancing on principle; a
// zero-length range cannot happen, because the query is non-empty.
let next = found.location + max(1, found.length)
searched = NSRange(location: next, length: max(0, text.length - next))
}
}
return matches
}
/// **Stepping, with wraparound** — the find bar's own grammar (Edit ▸ Find Next / Find Previous,
/// ⌘G / ⇧⌘G — 11-command-nexus.md), and the thing that makes a find spanning several text views
/// read as *one* find: next from the last hit of one comment is the first hit of the next.
///
/// `0` for an empty match list, which the caller never steps into anyway — stated so the function
/// is total rather than trapping on a modulo by zero.
public static func step(from index: Int, count: Int, forward: Bool) -> Int {
guard count > 0 else { return 0 }
let next = forward ? index + 1 : index - 1
return ((next % count) + count) % count
}
/// The bar's counter — "3 of 12", or the not-found phrase. Pure for the reason every count line in
/// this app is: the zero case is the one that renders wrong.
public static func status(index: Int, count: Int, query: String) -> String {
guard !query.isEmpty else { return "" }
guard count > 0 else { return "No matches" }
return "\(index + 1) of \(count)"
}
}
// MARK: - CommentThreadFind
/// **The comments pane's find session** — the bar's state, the matches, and where the current one is
/// (05-card-window.md ▸ Preview's comments clause).
///
/// ### Why not `NSTextFinder`
///
/// Because there is no one text view to give it. The thread is a list of rows, each a real
/// `NSTextView` with its own storage (`CommentBodyView`, whose note explains why a scroll view per row
/// was rejected), and `NSTextFinder` finds within **one** client. The two ways out are a
/// discontiguous `NSTextFinderClient` vending the concatenated thread and mapping ranges back to rows,
/// or this: a small find bar over a **model** of the rendered thread, driving the rows' highlighting.
/// This is the smaller correct one — the rows already know how to draw a highlight, the model is a
/// pure function (`CommentThreadSearch`) rather than a protocol conformance whose contract is only
/// observable by using it, and it searches comments whose rows are not even mounted, which a client
/// built over live text views could not.
///
/// ### The honest limits, stated
///
/// - **It is the app's bar, not the system's.** Its grammar is deliberately the system's — a field,
/// a counter, previous/next, Done, ⌘G/⇧⌘G, Escape — but Replace, the search-options menu (whole
/// word, regular expression) and the find pasteboard are not there. The body's ⌘F is still the real
/// `NSTextFinder`, and so is the composer's, so the two surfaces that are *documents* keep the full
/// thing; the thread, which is a list, gets a list's find.
/// - **It searches bodies, not author lines.** The thread's content is what a reader means by "find
/// in this thread", and the author line is metadata rendered beside it, in a SwiftUI `Text` with no
/// range to highlight. Names are searchable where names are content — the board's query over titles.
/// - **The rows are rebuilt when the thread changes.** That is a render pass per reload *while the bar
/// is open*, which is why the rows are built lazily and dropped with the bar.
@MainActor
@Observable
public final class CommentThreadFind {
/// Whether the bar is on screen. ⌘F raises it; Done and Escape take it down.
public private(set) var isShowing = false
/// The query, written live by the bar's field.
public var query = "" {
didSet {
guard query != oldValue else { return }
recompute(preservingPosition: false)
}
}
/// Every hit in the thread, in reading order.
public private(set) var matches: [CommentThreadMatch] = []
/// Which hit is current — an index into `matches`, meaningless (and unread) when it is empty.
public private(set) var index = 0
/// Bumped to ask the bar's field for the keyboard. A **counter**, not a flag, for
/// `CardComments.focusComposerRequests`' reason: two ⌘Fs in a row are two requests.
public private(set) var focusRequests = 0
/// The rendered thread the matches were computed against, rebuilt whenever the thread changes
/// while the bar is open.
private var rows: [CommentThreadRow] = []
public init() {}
// MARK: The bar
/// **⌘F over a focused comments pane**: raise the bar if it is down, and put the keyboard in its
/// field either way — the same "focus, not toggle" rule Edit ▸ Find keeps on the board
/// (`FindCommand`), so a second press is a re-focus rather than a dismissal.
public func invoke() {
isShowing = true
focusRequests += 1
}
/// Done, and Escape — the bar's two ways out. The query is kept: re-opening the bar on the search
/// the user was just running is what every find bar on this platform does.
public func dismiss() {
isShowing = false
}
/// The thread, as the pane knows it — called when the bar opens and whenever the thread changes
/// under an open bar.
///
/// **The position survives a re-read**: an agent editing a comment three rows up must not move the
/// user's place in their own search, so the index is clamped rather than reset. It *is* reset by a
/// query change, which is a new search.
public func setThread(_ comments: [Comment], pointSize: CGFloat) {
let rows = comments.map { comment in
CommentThreadRow(id: comment.id, text: Self.renderedText(of: comment.body, pointSize: pointSize))
}
guard rows != self.rows else { return }
self.rows = rows
recompute(preservingPosition: true)
}
/// The rendered plain text of one comment body — **the same renderer the row draws with**
/// (`CommentBodyView` → `BodyMarkupRenderer`), so a range found here lands on the characters the
/// user is looking at. A second way of turning Markdown into text would be a second answer to
/// "what does this comment say", and the ranges would drift wherever the two disagreed.
private static func renderedText(of body: String, pointSize: CGFloat) -> String {
BodyMarkupRenderer.attributedString(
for: BodyMarkup.parse(body),
context: BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: nil)
).string
}
// MARK: Stepping
/// Edit ▸ Find Next / Find Previous, and the bar's own chevrons. A no-op with nothing found.
public func step(forward: Bool) {
guard !matches.isEmpty else { return }
index = CommentThreadSearch.step(from: index, count: matches.count, forward: forward)
}
/// The hit the pane scrolls to and draws in the current colour, or `nil` when there is none.
///
/// **`nil` with the bar down**, which is what takes the highlighting off the thread when the user
/// presses Done: the query survives a dismissal (so re-opening lands on the same search), and
/// without this gate the matches would survive as marks on a thread nobody is searching.
public var currentMatch: CommentThreadMatch? {
guard isShowing, matches.indices.contains(index) else { return nil }
return matches[index]
}
/// Every hit inside one comment — what a row highlights. `[]` for a row with none, which is the
/// overwhelming majority and costs the row nothing, and `[]` for every row while the bar is down.
public func matches(in comment: ItemID) -> [NSRange] {
guard isShowing else { return [] }
return matches.compactMap { $0.comment == comment ? $0.range : nil }
}
/// The bar's counter.
public var status: String {
CommentThreadSearch.status(index: index, count: matches.count, query: query)
}
private func recompute(preservingPosition: Bool) {
matches = CommentThreadSearch.matches(in: rows, query: query)
index = preservingPosition ? min(index, max(0, matches.count - 1)) : 0
}
}
// MARK: - The bar
/// The comments pane's find bar — **the system find bar's grammar, in the app's own strip**
/// (see `CommentThreadFind` for why it is not an `NSTextFinder` bar).
///
/// A field, a counter, previous/next, Done: the four things a find bar on this platform has, in the
/// order it has them, so a user who has used the body's ⌘F recognises this one. Return steps forward
/// and Escape dismisses — the field's own two keys — while ⌘G/⇧⌘G are the menu's
/// (`FindSteppingCommands`), which is where the platform puts stepping that must work with the
/// keyboard anywhere in the window.
///
/// **⇧Return is deliberately not bound.** It is the obvious twin of Return, and binding it would mean
/// a window-wide key equivalent that outranks the composer's newline for as long as the bar is up —
/// a bar left open while the user goes back to typing would silently swallow their line breaks.
struct CommentFindBar: View {
let find: CommentThreadFind
@FocusState private var isFieldFocused: Bool
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
var body: some View {
HStack(spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize)) {
field
Text(find.status)
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
// A live-updating count is a status, not a control: announced when it changes rather
// than only when the rotor reaches it (10-accessibility.md's live-region posture).
.accessibilityLabel(find.status)
stepper(forward: false, symbol: "chevron.up", label: "Find Previous")
stepper(forward: true, symbol: "chevron.down", label: "Find Next")
Button("Done") { find.dismiss() }
.controlSize(.small)
}
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
.padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: pointSize))
.background(.background.secondary)
.overlay(alignment: .bottom) { Divider() }
// ⌘F's second half: the bar is raised by the menu item one update earlier, so the keyboard is
// claimed here, where the field exists (`BoardSearchFieldView.updateNSView`'s rule).
.onChange(of: find.focusRequests, initial: true) { _, _ in
isFieldFocused = true
}
}
private var field: some View {
TextField("Find", text: Binding(get: { find.query }, set: { find.query = $0 }))
.textFieldStyle(.roundedBorder)
.controlSize(.small)
.frame(width: CardWindowMetrics.findFieldWidth(bodyPointSize: pointSize))
.focused($isFieldFocused)
.onSubmit { find.step(forward: true) }
// Escape dismisses the bar and nothing else — it never clears the query, which is the
// find pasteboard's convention and the reason re-opening lands on the same search.
.onExitCommand { find.dismiss() }
.accessibilityLabel("Find in comments")
}
private func stepper(forward: Bool, symbol: String, label: String) -> some View {
Button {
find.step(forward: forward)
} label: {
Image(systemName: symbol)
.font(.caption.weight(.semibold))
}
.buttonStyle(.plain)
.disabled(find.matches.isEmpty)
.help(label)
.accessibilityLabel(label)
}
}