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) } }