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
367 lines
18 KiB
Swift
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)
|
|
}
|
|
}
|