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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user