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
+128
View File
@@ -226,6 +226,134 @@ enum AccessibilityPhrases {
}
}
// MARK: - The comments pane
/// The pane's container label **"Comments, N"** (10-accessibility.md Comments: "the pane is a
/// labeled container ('Comments, N')").
///
/// The count is the *thread's*, not the rendered rows' there is no filter over a thread and it
/// is the same number the visible header shows (`CommentsHeader.title(count:)`), which is the same
/// discipline the lane label keeps with its badge.
static func commentsContainerLabel(count: Int) -> String {
"Comments, \(count)"
}
/// "3 comments", "1 comment" the pane's plural folding, beside `cardCount` and `laneCount`.
static func commentCount(_ count: Int) -> String {
"\(count) comment\(count == 1 ? "" : "s")"
}
/// **One comment as a single flattened element's label** (10 Comments: "each comment is **one
/// flattened element** author, date, edited state, body").
///
/// The author line is the label and the body is the value (`commentValue`), which is the same split
/// the card element makes: the label is what the element *is*, the value is what it currently
/// holds. A comment with no author line at all no name, no date falls back to the word
/// "Comment", because an unlabeled element is an audit failure and "unattributed" is the absence of
/// a name rather than a name to speak (`CommentAuthorLine`).
static func commentLabel(authorLine: String?) -> String {
guard let authorLine, !authorLine.isEmpty else { return commentSubject }
return authorLine
}
/// What a comment with nothing to attribute is called. Named rather than inlined because both the
/// label fallback and the empty pane's hint read it.
static let commentSubject = "Comment"
/// A comment element's **value**: its body, with the attachment count appended when it has files.
///
/// The body is spoken as it is rather than summarized: 10 puts "body" in the flattened element, and
/// a comment is short by nature the thing a thread is *for* is the text, and paraphrasing it
/// would be the app deciding what a VoiceOver user may hear of somebody's comment. Empty for a
/// comment with neither, which speaks as nothing (`cardValue`'s rule).
static func commentValue(body: String, attachments: Int) -> String {
var parts: [String] = []
let text = body.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty { parts.append(text) }
if attachments > 0 { parts.append(attachmentCount(attachments)) }
return parts.joined(separator: ", ")
}
/// The three custom actions a comment element carries its context menu's rows, which is what 10
/// asks for ("with its context-menu rows (Edit / Delete / Reveal in Finder) riding as custom
/// actions per the cut").
///
/// Constants rather than literals at the modifier because the menu row and the custom action must
/// be the *same* string: a user who has learned the pointer inventory should hear the same three
/// words from the rotor.
static let commentEditAction = "Edit"
static let commentDeleteAction = "Delete"
static let commentRevealAction = "Reveal in Finder"
/// The composer, labeled "the composer is a labeled text field ( posts)" (10 Comments).
static let commentComposerLabel = "Add a comment"
/// The sort control's label. Its *value* is the direction's own word
/// (`CommentSortDirection.controlLabel`), which is also its help text one string, three readers.
static let commentSortLabel = "Sort"
/// The paperclip on either authoring surface.
static let commentAttachFilesLabel = "Attach Files"
// MARK: - Foreign comment changes
/// **What a foreign comment change says out loud** path-shaped, naming the card
/// (10-accessibility.md Comments: "Foreign comment arrivals announce path-shaped ('New comment on
/// 'card'') the window-scoped read never blocks the announcement, which composes from the path
/// alone").
///
/// ### Precedence, not concatenation
///
/// One polite sentence per reload is the whole ladder's rule (`BoardAnnouncer`), so three
/// simultaneous kinds of change pick one: **arrivals lead** they are the news 10 names and the
/// only kind that adds something to read then edits, then deletions. A thread that gained one
/// comment and lost another says the gain; the loss is visible in the pane and has no reader
/// waiting on it.
///
/// ### The verbs are 06's family, and the plurals are built on them
///
/// 10 names one sentence and 01 names the family the other two come from ("a changed path under
/// `/comments/<uuid>/` composes 'Comment on card title' / 'Edit comment on' / 'Delete comment
/// on'"). The singular forms are those verbs verbatim; the plural forms fold a count in front,
/// like every other count in the app, rather than repeating the sentence N times.
static func commentsChanged(_ changes: CommentThreadChanges, onCard title: String?) -> String? {
let card = displayTitle(title)
if let phrase = commentFragment(
count: changes.arrived.count,
singular: "New comment",
plural: { "\($0) new comments" },
onCard: card
) {
return phrase
}
if let phrase = commentFragment(
count: changes.edited.count,
singular: "Edit comment",
plural: { "\($0) comments edited" },
onCard: card
) {
return phrase
}
return commentFragment(
count: changes.deleted.count,
singular: "Delete comment",
plural: { "\($0) comments deleted" },
onCard: card
)
}
/// One bucket's sentence, or `nil` at zero the shape all three share, written once so the three
/// cannot drift in punctuation or in where the card's name sits.
private static func commentFragment(
count: Int,
singular: String,
plural: (Int) -> String,
onCard card: String
) -> String? {
guard count > 0 else { return nil }
return "\(count == 1 ? singular : plural(count)) on '\(card)'"
}
// MARK: - The banner strip
/// What VoiceOver says before a banner's headline. "Status" rather than "Info" because that is
+12 -1
View File
@@ -194,11 +194,16 @@ extension FocusedValues {
/// **A board window in front is now the whole of the scope**, where m5 additionally required the
/// toolbar's field to exist: with the item removed, F raises the transient host instead
/// (`BoardSearchPresentation.invokeSearch`), so there is no board window where the row is dead.
/// **The card window's find routes by focus** (05-card-window.md Preview, comments clause): the
/// body's substrate when the body has the keyboard, the whole rendered thread when the comments pane
/// does, and that editor's own stock find bar when the composer or an inline session does. The rule
/// itself is `CardWindowFind.route`, pure and pinned; this row only asks it.
struct FindCommand: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardSearch) private var search
@FocusedValue(\.cardBody) private var cardBody
@FocusedValue(\.cardComments) private var comments
/// **The card window wins when it is the focused scene**, which is the whole of 11's split
/// ("Board window: board search; card window: find-in-text"): the two never both publish, so
@@ -209,6 +214,12 @@ struct FindCommand: View {
var body: some View {
Button("Find") {
// The pane answers first and says whether it took the key: with the comments pane focused
// or its find bar already up F is the thread's, and every other case falls through to
// the body exactly as it did before the pane existed.
if comments?.invokeFind(hasBody: findInText != nil) == true {
return
}
if let findInText {
findInText()
} else {
@@ -216,7 +227,7 @@ struct FindCommand: View {
}
}
.keyboardShortcut("f", modifiers: .command)
.disabled(findInText == nil && (store == nil || search == nil))
.disabled(findInText == nil && comments == nil && (store == nil || search == nil))
}
}
+7 -2
View File
@@ -334,11 +334,16 @@ struct BoardView: View {
// arrivers are the card and row transitions already attached inside the lanes and the trash
// column; this is the survivors' spring around them.
//
// **Every way the query changes rides it**, which is the reason the key is the query rather
// **Every way the filter changes rides it**, which is the reason the key is the filter rather
// than the transaction being wrapped at each mutation: typing, the field's Escape, the
// board's Escape, and creation's clear (`TransientBoardState.beginPlaceholder`) all land
// here without any of them knowing about motion.
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchQuery)
//
// The *filter*, not the query, since comments joined the search's scope (04 Search, re-ruled
// 2026-07-29): a landed comment-index sweep widens what matches without the query changing at
// all, and keying on the query alone would make those cards pop in while every other arrival
// eased. `SearchFilter` is `Equatable` and carries the index' answer, so one key covers both.
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchFilter)
}
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
+126 -1
View File
@@ -44,6 +44,11 @@ public final class CardComments {
/// Finder resolve against it.
public var cardFolder: URL?
/// The card's title, as the announcer needs it **the subject of every path-shaped comment
/// sentence** ("New comment on 'card'"). Re-derived from every snapshot by the host, so a card
/// renamed mid-session is announced under its new name.
public var cardTitle: String?
/// Whether the pane's mutations are offered at all `!store.isReadOnly`. Under the lock the
/// composer, the paperclips, Post, Edit and Delete disable in place, which is
/// 02-architecture.md's every-entry-point predicate applied to this pane.
@@ -65,6 +70,29 @@ public final class CardComments {
/// a `Bool` would need clearing, and a clear that raced the view would swallow the second one.
public private(set) var focusComposerRequests = 0
// MARK: Find
/// **The pane's find session** Edit Find over the whole rendered thread (05-card-window.md
/// Preview; `CommentThreadFind`). One per window like everything else here, because two card
/// windows are two threads and two searches.
public let find = CommentThreadFind()
/// **Which surface inside the pane holds the keyboard**, as its own text views report it the
/// input F routes on (`CardWindowFind.route`).
///
/// `BoardSearchPresentation.isFocused`'s shape and for its reason: it is the *view's* answer,
/// written on `becomeFirstResponder`/`resignFirstResponder`, which is what makes it true for
/// AppKit's own key-view traversal as well as for a click. Inferring it from SwiftUI focus state
/// would be inferring it from the wrong responder chain every text surface in this pane is an
/// `NSTextView`.
public private(set) var paneFocus: CommentPaneFocus?
/// Puts the stock find bar over whichever authoring editor is focused filled in by that editor
/// as it takes the keyboard, and cleared as it loses it. `nil` whenever the focus is not an
/// authoring surface, which is exactly when there is no such find to run.
@ObservationIgnored
public private(set) var authoringFindInText: (() -> Void)?
// MARK: Seams filled in by the host with the store's own bracketed methods
/// Re-reads the thread `BoardStore.commentThread(inCard:)`.
@@ -109,6 +137,19 @@ public final class CardComments {
@ObservationIgnored
public var removeAttachment: ((String, CommentTarget) -> Void)?
/// **Which of this thread's changes the app itself wrote** `BoardStore.vouchedComments(inCard:)`,
/// consumed once per reload so a foreign change is never mistaken for an echo or the other way
/// about (10-accessibility.md Live board announcements).
@ObservationIgnored
public var vouchedComments: (() -> Set<ItemID>)?
/// **This pane's outlet for spoken announcements** `AccessibilityAnnouncer.post`, the app's one
/// `NSAccessibility.post` call site, injectable exactly as `BoardStore.announce` is and for its
/// reason: what is *said* is decided by pure functions, and a suite has to be able to read the
/// sentence without a screen reader attached.
@ObservationIgnored
public var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
public init() {}
// MARK: - Reading
@@ -121,7 +162,11 @@ public final class CardComments {
/// board that closed cleanly (`HealScheduler`'s rest branch), which is every open but one.
public func open() {
sweepTrashResidue?()
reload()
// **The opening read announces nothing.** Every comment on the card is "new" relative to the
// empty thread this pane starts with, and narrating a thread the user has just chosen to open
// would be the app describing its own window (10-accessibility.md's rationing, applied to the
// one reload that is not a change at all).
reload(announcing: false)
}
/// Re-reads the thread and the draft, and routes anything the read found to be repaired.
@@ -132,13 +177,25 @@ public final class CardComments {
/// because this runs from an `onChange` that may still be inside the store's own reload
/// transaction and a thread must not ride the board's structural spring.
public func reload() {
reload(announcing: true)
}
/// - Parameter announcing: whether a foreign change in this re-read is worth speech. `false` for
/// the window's opening read only (see `open()`); every other caller the FSEvents reload, and
/// the immediate re-read a gesture takes passes `true`, because the *narrowing* that keeps a
/// gesture silent is the ledger's rather than the call site's.
private func reload(announcing: Bool) {
guard let readThread else { return }
let previous = thread
let thread = readThread()
let draft = readDraft?()
withAnimation(nil) {
self.thread = thread
}
if announcing {
announceForeignChanges(from: previous, to: thread)
}
composer.adopt(draft: draft)
// The open session follows disk under the same dirty-buffer-wins rule the body has: a clean
// editor takes the foreign edit, a dirty one keeps the keystrokes. A session whose comment
@@ -164,6 +221,27 @@ public final class CardComments {
}
}
/// **Foreign comment changes speak path-shaped** (10-accessibility.md Comments: "Foreign comment
/// arrivals announce path-shaped ('New comment on 'card'') the window-scoped read never blocks
/// the announcement, which composes from the path alone").
///
/// Three steps, each of them somebody else's rule: the two threads are diffed
/// (`CommentThreadChanges.between`), the ledger's receipts are consumed to narrow the result to
/// what nobody vouched for (`BoardStore.vouchedComments(inCard:)` which is where
/// `CommentPath.classify` reads the path shape), and the announcer picks one polite sentence
/// (`BoardAnnouncer.commentSpeech(for:onCard:)`). Nothing here is a decision, which is what lets
/// all three be checked without a window.
///
/// **The receipts are consumed whether or not the thread changed.** A reload that observed an
/// app-mediated edit and a reload that observed nothing must both leave the ledger clean: an
/// uncollected comment receipt would silence the *next* change to that comment, which is exactly
/// the misattribution the one-write-one-echo rule exists to prevent.
private func announceForeignChanges(from old: CommentThread, to new: CommentThread) {
let vouched = vouchedComments?() ?? []
let changes = CommentThreadChanges.between(old, new).excluding(vouched)
announce(BoardAnnouncer.commentSpeech(for: changes, onCard: cardTitle))
}
// MARK: - The composer
/// **File Add Comment**, and the pane's own "add a comment" affordances: ask the composer for
@@ -176,6 +254,53 @@ public final class CardComments {
focusComposerRequests += 1
}
// MARK: - Focus, as the pane's text views report it
/// One of the pane's text surfaces took the keyboard.
///
/// - Parameter findInText: the stock find bar over *that* editor, for an authoring surface. A
/// reading surface passes none its find is the thread's, which is this object's own.
public func focusEntered(_ focus: CommentPaneFocus, findInText: (() -> Void)? = nil) {
paneFocus = focus
authoringFindInText = findInText
}
/// One of them lost it **ignored unless it is still the one on record**.
///
/// AppKit resigns the outgoing responder before the incoming one becomes first, so the ordinary
/// case is already safe; the guard covers the one that is not, a late resignation arriving after
/// a sibling has claimed focus. Without it, clicking from one comment straight into the composer
/// could leave the pane reporting no focus at all.
public func focusLeft(_ focus: CommentPaneFocus) {
guard paneFocus == focus else { return }
paneFocus = nil
authoringFindInText = nil
}
/// **F with this pane focused** routed by `CardWindowFind.route`, which is where the rule lives.
/// Answers whether it handled the key, so `FindCommand` can fall through to the body surface.
@discardableResult
public func invokeFind(hasBody: Bool) -> Bool {
switch CardWindowFind.route(
paneFocus: paneFocus,
isThreadFindShowing: find.isShowing,
hasBody: hasBody
) {
case .thread:
find.invoke()
return true
case .authoring:
// "The composer and an inline comment edit are their own focused text surfaces with the
// editor's ordinary find" (05). The editor already has a find bar and a scroll view to put
// it in (`CommentTextEditor`); all that was missing is the key, which the menu item's
// equivalent takes before any text view sees it.
authoringFindInText?()
return true
case .body, nil:
return false
}
}
// MARK: - The inline edit session
/// Opens a session over one comment the context menu's **Edit** (05 The comments column).
+38 -5
View File
@@ -50,14 +50,38 @@ struct CardCommentsPane: View {
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: pointSize))
// **The find bar sits above the thread it searches** `findBarPosition = .aboveContent`,
// which is where every other find in this window puts its bar (`CardBodySurface`).
if comments.find.isShowing {
CommentFindBar(find: comments.find)
.padding(.top, CardWindowMetrics.previewPadding(bodyPointSize: pointSize))
}
thread
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// 10-accessibility.md's container label for the pane ("Comments, N"). The elements inside it
// and their custom actions are phase 3's; the container is here because the pane would
// otherwise be an unnamed region the moment it exists.
// 10-accessibility.md's container label for the pane ("Comments, N") the count is the
// thread's, so the spoken container and the visible header can never disagree.
.accessibilityElement(children: .contain)
.accessibilityLabel("Comments, \(comments.thread.comments.count)")
.accessibilityLabel(AccessibilityPhrases.commentsContainerLabel(count: comments.thread.comments.count))
// The find's model of the rendered thread, rebuilt only while the bar is up: the search runs
// over every comment, mounted or not (`CommentThreadFind`), and building it costs a render
// pass the pane has no reason to spend on a window nobody is searching.
.onChange(of: FindThreadKey(isShowing: comments.find.isShowing, thread: comments.thread), initial: true) { _, key in
guard key.isShowing else { return }
comments.find.setThread(key.thread.comments, pointSize: pointSize)
}
// **A find cannot outlive the surface it searches.** The pane unmounts on View Show Comments
// and on the raw-source swap, and a session left showing would keep F and G pointed at a bar
// nobody can see (`CardWindowFind.route`'s open-bar clause).
.onDisappear { comments.find.dismiss() }
}
/// The pair the find's rebuild is keyed on. A value rather than two `onChange`s so the bar opening
/// and the thread changing take the same path, and so the rebuild cannot run twice for one update.
private struct FindThreadKey: Equatable {
let isShowing: Bool
let thread: CommentThread
}
// MARK: - Header
@@ -88,7 +112,7 @@ struct CardCommentsPane: View {
}
.buttonStyle(.plain)
.help(direction.controlLabel)
.accessibilityLabel("Sort")
.accessibilityLabel(AccessibilityPhrases.commentSortLabel)
.accessibilityValue(direction.controlLabel)
}
@@ -129,6 +153,15 @@ struct CardCommentsPane: View {
.onChange(of: comments.focusComposerRequests) { _, _ in
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
}
// **A find that steps off screen brings the reader with it** the half of "it reads as one
// find" that the highlight alone cannot do. Scrolling to the comment rather than to the
// range is what a row-shaped thread allows: rows are the scroll targets
// (`ForEach(...).id(comment.id)`), and a comment is short enough that its top is the hit's
// neighbourhood.
.onChange(of: comments.find.currentMatch) { _, match in
guard let match else { return }
proxy.scrollTo(match.comment, anchor: .center)
}
}
}
+9
View File
@@ -167,6 +167,15 @@ enum CardWindowMetrics {
(lineHeight(bodyPointSize: bodyPointSize) * 5.5).rounded()
}
/// The find bar's query field **twelve characters**, the shortest measure that still shows a
/// two-word search whole. It is deliberately narrower than the board's search field (seventeen):
/// this bar shares a strip with a counter and three controls inside a fixed-width pane, and a
/// field sized to the query would push the Done button off the end of the narrowest window the
/// design allows.
static func findFieldWidth(bodyPointSize: CGFloat) -> CGFloat {
columnWidth(characters: 12, bodyPointSize: bodyPointSize)
}
/// The gap between two comments in the thread a full gutter, one step larger than the rhythm
/// *inside* a comment (`sidebarRowSpacing`), so the eye groups an author line with its body
/// rather than with its neighbour.
+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
}
}
+10 -3
View File
@@ -88,7 +88,11 @@ struct CommentAuthoringSurface<Actions: View>: View {
onCommandReturn: onCommandReturn,
onEscape: onEscape,
onBlur: onBlur,
focusRequest: focusRequest
focusRequest: focusRequest,
// Both authoring surfaces report themselves to the pane's focus register, which is
// what routes F here to the editor's own find bar rather than to the thread's
// (05 Preview; `CardWindowFind.route`).
focus: comments
)
.frame(height: height)
@@ -124,7 +128,7 @@ struct CommentAuthoringSurface<Actions: View>: View {
.buttonStyle(.plain)
.disabled(!comments.isEditable)
.help("Attach Files…")
.accessibilityLabel("Attach Files")
.accessibilityLabel(AccessibilityPhrases.commentAttachFilesLabel)
}
}
@@ -183,7 +187,10 @@ struct CommentComposerView: View {
.controlSize(.small)
.disabled(!comments.isEditable || !session.canPost)
}
.accessibilityLabel("Add a comment")
// "The composer is a labeled text field ( posts)" 10-accessibility.md Comments. The
// label is the placeholder's own sentence, so what a sighted user reads in the empty editor
// and what VoiceOver announces are the same invitation.
.accessibilityLabel(AccessibilityPhrases.commentComposerLabel)
}
/// Post, then re-read: the rename moved a folder into the thread, and the pane shows the thread.
+94 -19
View File
@@ -38,42 +38,70 @@ struct CommentRowView: View {
var body: some View {
VStack(alignment: .leading, spacing: padding) {
if let line = authorLine {
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let session {
authorText
editor(session)
} else {
reading
if !comment.attachments.isEmpty {
// **Read-only** "a posted comment's chips are read-only, Quick Look only Edit
// the comment to change its files" (05). `onRemove` left `nil` is that sentence,
// and the editing branch above draws its *own* removable chips inside the
// authoring surface, which is why these are the reading branch's alone.
//
// They sit **outside** the flattened element deliberately: 10 flattens "author,
// date, edited state, body", and the chips are the one part of a comment that is
// not text but a row of controls with a Quick Look behind each reachable exactly
// as the sidebar's are (`CommentAttachmentChips`). Flattening them in would have
// made a comment's files announceable but not openable.
CommentAttachmentChips(
names: comment.attachments,
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
thumbnails: thumbnails
)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.contextMenu { menu }
.accessibilityElement(children: .contain)
.accessibilityLabel(authorLine ?? "Comment")
}
// MARK: - Reading
/// The author line and the rendered body, as **one flattened element** with the three custom
/// actions (10-accessibility.md Comments see `CommentRowAccessibility`).
private var reading: some View {
VStack(alignment: .leading, spacing: padding) {
CommentBodyView(body: comment.body, cardFolder: cardFolder)
.frame(maxWidth: .infinity, alignment: .leading)
authorText
if !comment.attachments.isEmpty {
// **Read-only** "a posted comment's chips are read-only, Quick Look only Edit the
// comment to change its files" (05). `onRemove` left `nil` is that sentence.
CommentAttachmentChips(
names: comment.attachments,
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
thumbnails: thumbnails
)
}
CommentBodyView(
body: comment.body,
cardFolder: cardFolder,
// The find's hits in this comment, and whether the current one is here the row draws
// them, the session found them (`CommentThreadFind`).
highlights: comments.find.matches(in: comment.id),
currentHighlight: comments.find.currentMatch.flatMap {
$0.comment == comment.id ? $0.range : nil
},
focus: comments
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.modifier(CommentRowAccessibility(comment: comment, comments: comments, authorLine: authorLine))
}
/// The author line, or nothing "a comment with no `author` renders **without a name**"
/// (`CommentAuthorLine`).
@ViewBuilder
private var authorText: some View {
if let line = authorLine {
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
@@ -146,3 +174,50 @@ struct CommentRowView: View {
date.formatted(date: .abbreviated, time: .shortened)
}
}
// MARK: - The row as one element
/// **One comment is one flattened element with three custom actions** (10-accessibility.md
/// Comments):
///
/// > each comment is **one flattened element** author, date, edited state, body with its
/// > context-menu rows (Edit / Delete / Reveal in Finder) riding as custom actions per the cut.
///
/// ### It covers the reading surface only, and that is the whole of where it is applied
///
/// While an inline edit session is open the row is a text editor with two buttons in it, and
/// collapsing *that* into one opaque element would put a VoiceOver user in front of a comment they
/// can neither read into nor type into. So this hangs on the reading branch alone
/// (`CommentRowView.reading`), and the editing branch is an ordinary container the same asymmetry
/// the body column keeps between Preview and Edit.
///
/// The actions duplicate the context menu deliberately: 10 asks for the pointer inventory to be
/// reachable without the pointer, and the strings are shared with the menu (`AccessibilityPhrases`)
/// so the two inventories cannot drift. They are also **not** disabled under the read-only lock
/// they call the same handles the menu rows do, and those already refuse (`CardComments.beginEdit`,
/// `.delete`), which keeps one refusal rather than two.
private struct CommentRowAccessibility: ViewModifier {
let comment: Comment
let comments: CardComments
let authorLine: String?
func body(content: Content) -> some View {
content
.accessibilityElement(children: .ignore)
.accessibilityLabel(AccessibilityPhrases.commentLabel(authorLine: authorLine))
.accessibilityValue(AccessibilityPhrases.commentValue(
body: comment.body,
attachments: comment.attachments.count
))
.accessibilityAction(named: AccessibilityPhrases.commentEditAction) {
comments.beginEdit(comment.id)
}
.accessibilityAction(named: AccessibilityPhrases.commentDeleteAction) {
comments.delete(comment.id)
}
.accessibilityAction(named: AccessibilityPhrases.commentRevealAction) {
comments.reveal(comment.id)
}
}
}
+42
View File
@@ -44,6 +44,11 @@ struct CommentTextEditor: NSViewRepresentable {
/// Bumped to ask for the keyboard File Add Comment's second half, and an inline session
/// opening. A counter rather than a flag: two requests in a row are two requests.
var focusRequest: Int = 0
/// The pane's focus register. This surface reports itself as an **authoring** one, which is what
/// makes F here the editor's ordinary find rather than the thread's (05-card-window.md Preview:
/// "the composer and an inline comment edit are their own focused text surfaces with the editor's
/// ordinary find"). `nil` in a preview or a test that mounts the editor alone.
var focus: CardComments?
func makeCoordinator() -> Coordinator {
Coordinator()
@@ -98,6 +103,25 @@ struct CommentTextEditor: NSViewRepresentable {
context.coordinator.textView = textView
context.coordinator.onEdit = onEdit
context.coordinator.onBlur = onBlur
// The pane's focus register, and while this editor holds the keyboard the find F runs.
// `performTextFinderAction` takes its verb from the sender's `tag`, which is how the standard
// Edit Find item drives it (`CardBodySurface`'s own note); the menu item's key equivalent
// fires before this view ever sees F, so the action has to be reachable from outside.
let focus = focus
textView.onFocusChange = { [weak textView] gained in
guard gained else {
focus?.focusLeft(.authoring)
return
}
focus?.focusEntered(.authoring) { [weak textView] in
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
let sender = NSMenuItem()
sender.tag = NSTextFinder.Action.showFindInterface.rawValue
textView.performTextFinderAction(sender)
}
}
return scrollView
}
@@ -187,6 +211,24 @@ final class CommentEditorTextView: NSTextView {
var onCommandReturn: (() -> Void)?
var onEscape: (() -> Void)?
/// **It says when it has the keyboard** `CommentBodyTextView`'s pair, and for its reason: F's
/// route is a focus question, and the responder is the only thing that can answer it. Reported
/// here rather than through `textDidEndEditing` because that fires when the *field editor* ends,
/// which for an uneditable editor (the read-only lock) never happens at all.
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
}
/// **** "Post the draft / end the edit session at its commit point"
/// (11-command-nexus.md Fixed grammar keys). Intercepted before `super`, which would otherwise
/// insert a newline: the chord is the gesture, not a decorated Return.
+366
View File
@@ -0,0 +1,366 @@
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)
}
}