The stacked mount reads as one document in view mode — title, body and the thread share a single scroll
Preview, stacked (over/under): title, the rendered body and the comment thread now stack in one continuous document with one scroll, instead of the fixed ≈3:2 split with each pane keeping its own. Edit mode keeps the split unchanged (an editor needs a stable scroll of its own), and the beside mount is untouched. CardBodySurface gains a `scrolls` flag: false switches off the hosted NSScrollView's scroller and elasticity and reports the NSTextView's own height for the proposed width via `sizeThatFits`, the layout-manager height-fit trick CommentBodyView already uses one level up. CardCommentsPane gains an `embeddedProxy`: supplied, it renders the same header, find bar, rows and composer without wrapping them in a second ScrollView, driving scrollTo off the outer document's proxy instead of its own. CardWindowView composes the two behind a new pure predicate, CommentsMount.showsContinuousDocument(mode:), tested in CardCommentsLayoutTests. The continuous↔split swap within stacked mount is a genuine remount of the body pane (two independent scrolls can't become one shared scroll by reconfiguration) — the same accepted cost the raw-source outlet already takes elsewhere in this window. No new animation on that swap, matching this file's existing precedent (the raw-source swap and the beside↔stacked mount switch are both instant cuts today). Decisions recorded on the card's thread, flagged for owner review where they're user-visible. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -30,6 +30,17 @@ struct CardCommentsPane: View {
|
||||
/// The window's thumbnail memory, shared with the sidebar's attachment rows so a file shown in
|
||||
/// both places is rendered once.
|
||||
let thumbnails: AttachmentThumbnailCache
|
||||
/// **The proxy of an outer `ScrollViewReader` this pane's content already lives inside** — the
|
||||
/// stacked mount's continuous Preview arrangement (`CardWindowView.continuousStackedContent`),
|
||||
/// where title, body and the thread share one document scroll rather than this pane owning its
|
||||
/// own. `nil`, the default, is every other mount and mode: this pane wraps its own content in a
|
||||
/// `ScrollView`/`ScrollViewReader` exactly as it always has.
|
||||
///
|
||||
/// **Nothing about a comment row, the composer, the find bar or the header changes with this** —
|
||||
/// the type's own doc above ("it does not know where it is mounted") extended one step further:
|
||||
/// the one thing that does change is which `ScrollViewProxy` a scrollTo call reaches for, and
|
||||
/// whether this view supplies the `ScrollView` those calls need a proxy over in the first place.
|
||||
var embeddedProxy: ScrollViewProxy? = nil
|
||||
|
||||
/// **App-wide and persisted** (05 ▸ The comments column; 11-command-nexus.md files the header
|
||||
/// control under Configuration controls). Read here rather than mirrored onto the window's handle
|
||||
@@ -59,7 +70,14 @@ struct CardCommentsPane: View {
|
||||
|
||||
thread
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// **`maxHeight` drops out while embedded.** The standalone mounts hand this pane a *fixed*
|
||||
// remaining height to fill (the beside `HStack`'s column, the stacked split's own row under
|
||||
// its `GeometryReader`), so `.infinity` there is exactly what claims all of it. Embedded, the
|
||||
// outer document `ScrollView` proposes an *unbounded* height instead, and a pane that still
|
||||
// claimed `.infinity` of an unbounded proposal would grow to its content's ideal height
|
||||
// anyway — but stating `nil` here is the honest version of that rather than a coincidence
|
||||
// this file would have to keep re-deriving by eye.
|
||||
.frame(maxWidth: .infinity, maxHeight: embeddedProxy == nil ? .infinity : nil, alignment: .topLeading)
|
||||
// 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)
|
||||
@@ -118,51 +136,71 @@ struct CardCommentsPane: View {
|
||||
|
||||
// MARK: - The thread
|
||||
|
||||
/// The rows, the composer, and their own `ScrollView` — or, embedded, the same rows and composer
|
||||
/// without one, since `embeddedProxy`'s owner already supplies the scroll they sit inside.
|
||||
@ViewBuilder
|
||||
private var thread: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(alignment: .leading, spacing: CardWindowMetrics.commentSpacing(bodyPointSize: pointSize)) {
|
||||
if direction.placesComposerFirst {
|
||||
composer.id(Self.composerAnchor)
|
||||
}
|
||||
ForEach(ordered) { comment in
|
||||
CommentRowView(
|
||||
comment: comment,
|
||||
comments: comments,
|
||||
cardFolder: cardFolder,
|
||||
thumbnails: thumbnails
|
||||
)
|
||||
.id(comment.id)
|
||||
}
|
||||
if !direction.placesComposerFirst {
|
||||
composer.id(Self.composerAnchor)
|
||||
}
|
||||
if let embeddedProxy {
|
||||
threadRows(proxy: embeddedProxy)
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical) {
|
||||
threadRows(proxy: proxy)
|
||||
}
|
||||
.padding(CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
// **The window opens scrolled to the composer** (05). Deferred one turn rather than run
|
||||
// inline: `scrollTo` needs the content laid out to have somewhere to scroll to, and a
|
||||
// thread's rows measure their own rendered height (`CommentBodyView`).
|
||||
.task {
|
||||
await Task.yield()
|
||||
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows and the composer, at whichever end `direction` puts it — the one place either is
|
||||
/// laid out, standalone or embedded. `proxy` is this pane's own when standalone and the outer
|
||||
/// document's when embedded; the scrollTo calls below do not know which.
|
||||
@ViewBuilder
|
||||
private func threadRows(proxy: ScrollViewProxy) -> some View {
|
||||
LazyVStack(alignment: .leading, spacing: CardWindowMetrics.commentSpacing(bodyPointSize: pointSize)) {
|
||||
if direction.placesComposerFirst {
|
||||
composer.id(Self.composerAnchor)
|
||||
}
|
||||
// File ▸ Add Comment focuses the composer — which is no use if the composer is off
|
||||
// screen, so the same request scrolls to it. One request, both effects.
|
||||
.onChange(of: comments.focusComposerRequests) { _, _ in
|
||||
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
|
||||
ForEach(ordered) { comment in
|
||||
CommentRowView(
|
||||
comment: comment,
|
||||
comments: comments,
|
||||
cardFolder: cardFolder,
|
||||
thumbnails: thumbnails
|
||||
)
|
||||
.id(comment.id)
|
||||
}
|
||||
// **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)
|
||||
if !direction.placesComposerFirst {
|
||||
composer.id(Self.composerAnchor)
|
||||
}
|
||||
}
|
||||
.padding(CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// **The window opens scrolled to the composer** (05) — **standalone only**. Embedded, that
|
||||
// would mean skipping past the title and the body to the thread's newest end on every open,
|
||||
// which reads wrong for a single continuous document; the composer still gets the reader
|
||||
// brought to it by an explicit request (`focusComposerRequests`, just below), only the
|
||||
// *opening* auto-scroll is standalone-only. Deferred one turn rather than run inline:
|
||||
// `scrollTo` needs the content laid out to have somewhere to scroll to, and a thread's rows
|
||||
// measure their own rendered height (`CommentBodyView`).
|
||||
.task {
|
||||
guard embeddedProxy == nil else { return }
|
||||
await Task.yield()
|
||||
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
|
||||
}
|
||||
// File ▸ Add Comment focuses the composer — which is no use if the composer is off
|
||||
// screen, so the same request scrolls to it. One request, both effects.
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The composer
|
||||
|
||||
Reference in New Issue
Block a user