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:
@@ -267,61 +267,70 @@ struct CardWindowView: View {
|
||||
@ViewBuilder
|
||||
private var contentPanes: some View {
|
||||
if showComments {
|
||||
switch mount {
|
||||
case .beside:
|
||||
// The `GeometryReader` is new here for the divider's sake — a resizable column needs
|
||||
// the `HStack`'s own live width to clamp against (`clampedCommentsColumnWidth(in:)`),
|
||||
// exactly the reason the stacked branch below already reaches for one.
|
||||
GeometryReader { proxy in
|
||||
HStack(spacing: 0) {
|
||||
bodyColumn
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// **Preview, stacked: one continuous document** — checked ahead of the mount switch
|
||||
// below because it is the one case that is not a fixed division of the body and the
|
||||
// comments pane at all; everything under the switch still divides *something* (a width
|
||||
// in `.beside`, a height in `.stacked`), and this arrangement doesn't.
|
||||
if mount.showsContinuousDocument(mode: bodyPresentation.mode) {
|
||||
continuousStackedContent
|
||||
} else {
|
||||
switch mount {
|
||||
case .beside:
|
||||
// The `GeometryReader` is new here for the divider's sake — a resizable column needs
|
||||
// the `HStack`'s own live width to clamp against (`clampedCommentsColumnWidth(in:)`),
|
||||
// exactly the reason the stacked branch below already reaches for one.
|
||||
GeometryReader { proxy in
|
||||
HStack(spacing: 0) {
|
||||
bodyColumn()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
|
||||
CommentsColumnDivider(
|
||||
bodyPointSize: bodyPointSize,
|
||||
containerWidth: proxy.size.width,
|
||||
currentWidth: clampedCommentsColumnWidth(in: proxy.size.width),
|
||||
onChange: { commentsColumnWidthOverride = $0 },
|
||||
onCommit: { width in
|
||||
storedCommentsColumnWidth = Double(width)
|
||||
// The persisted figure now says the same thing the override does —
|
||||
// clearing it hands `commentsColumnWidth` back to one source of truth
|
||||
// rather than two that happen to agree.
|
||||
commentsColumnWidthOverride = nil
|
||||
}
|
||||
)
|
||||
CommentsColumnDivider(
|
||||
bodyPointSize: bodyPointSize,
|
||||
containerWidth: proxy.size.width,
|
||||
currentWidth: clampedCommentsColumnWidth(in: proxy.size.width),
|
||||
onChange: { commentsColumnWidthOverride = $0 },
|
||||
onCommit: { width in
|
||||
storedCommentsColumnWidth = Double(width)
|
||||
// The persisted figure now says the same thing the override does —
|
||||
// clearing it hands `commentsColumnWidth` back to one source of truth
|
||||
// rather than two that happen to agree.
|
||||
commentsColumnWidthOverride = nil
|
||||
}
|
||||
)
|
||||
|
||||
commentsPane
|
||||
// **User-resizable since 2026-08-09** — the sidebar's "resize flex
|
||||
// always goes to the body" (05 ▸ Composition) still holds between the
|
||||
// body and the *sidebar*; the body and the *comments column* now split
|
||||
// it by the divider's drag instead, clamped so neither pane's own floor
|
||||
// gives way (`CardWindowMetrics.clampedCommentsColumnWidth`).
|
||||
.frame(width: clampedCommentsColumnWidth(in: proxy.size.width))
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
commentsPane
|
||||
// **User-resizable since 2026-08-09** — the sidebar's "resize flex
|
||||
// always goes to the body" (05 ▸ Composition) still holds between the
|
||||
// body and the *sidebar*; the body and the *comments column* now split
|
||||
// it by the divider's drag instead, clamped so neither pane's own floor
|
||||
// gives way (`CardWindowMetrics.clampedCommentsColumnWidth`).
|
||||
.frame(width: clampedCommentsColumnWidth(in: proxy.size.width))
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .stacked:
|
||||
// The ≈3:2 split needs a height to divide, and a `GeometryReader` is the only way to
|
||||
// have one — `layoutPriority` and flexible frames express *preferences*, and this is
|
||||
// a ratio the design fixes. The body takes its share; the comments pane takes the
|
||||
// remainder, so the divider between them can never leave a gap or overlap.
|
||||
GeometryReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
bodyColumn
|
||||
.frame(height: mount.bodyHeight(in: proxy.size.height))
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
case .stacked:
|
||||
// Edit only, here — Preview took the branch above. The ≈3:2 split needs a height
|
||||
// to divide, and a `GeometryReader` is the only way to have one —
|
||||
// `layoutPriority` and flexible frames express *preferences*, and this is a ratio
|
||||
// the design fixes. The body takes its share; the comments pane takes the
|
||||
// remainder, so the divider between them can never leave a gap or overlap.
|
||||
GeometryReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
bodyColumn()
|
||||
.frame(height: mount.bodyHeight(in: proxy.size.height))
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
|
||||
Divider()
|
||||
Divider()
|
||||
|
||||
commentsPane
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
commentsPane
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bodyColumn
|
||||
bodyColumn()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
@@ -330,10 +339,49 @@ struct CardWindowView: View {
|
||||
CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails)
|
||||
}
|
||||
|
||||
/// **The stacked mount's continuous Preview arrangement** — "title, body, comments stacked
|
||||
/// directly atop each other in a continuous fashion" (the card this milestone implements): one
|
||||
/// `ScrollView`, so the body pane's own scroll switches off in favor of reporting its intrinsic
|
||||
/// height (`CardBodySurface`'s `scrolls: false`), and the comments pane's own `ScrollView`
|
||||
/// switches off the same way (`CardCommentsPane`'s `embeddedProxy`) — both panes' internals
|
||||
/// otherwise untouched: the same header, the same rendering, the same composer, the same row.
|
||||
///
|
||||
/// **Preview only** (`CommentsMount.showsContinuousDocument(mode:)`), and deliberately so: Edit
|
||||
/// needs its own stable scroll and its own session-scoped undo, which a shared document scroll
|
||||
/// cannot give it. Entering or leaving Edit therefore swaps this arrangement for the fixed ≈3:2
|
||||
/// split above — a genuinely different view of the body pane, not a reconfiguration of the one
|
||||
/// in `bodyColumn(embedded:)`'s doc — the same accepted cost the raw-source outlet already takes
|
||||
/// elsewhere in this window: the buffer survives the swap (`bodySession`, dirty-buffer-wins), the
|
||||
/// scroll position and the find state do not.
|
||||
private var continuousStackedContent: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
bodyColumn(embedded: true)
|
||||
|
||||
Divider()
|
||||
|
||||
CardCommentsPane(
|
||||
comments: comments,
|
||||
cardFolder: cardFolder,
|
||||
thumbnails: thumbnails,
|
||||
embeddedProxy: proxy
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body column
|
||||
|
||||
/// Title, the quiet created/modified line, then the body — 05's top-to-bottom order.
|
||||
private var bodyColumn: some View {
|
||||
///
|
||||
/// - Parameter embedded: `true` only from `continuousStackedContent`, which mounts this inside an
|
||||
/// outer document `ScrollView` rather than giving it a fixed or flexible height of its own —
|
||||
/// see `CardBodySurface.scrolls` for what that switches off. `false`, the default, is every
|
||||
/// other call site, byte-for-byte the arrangement this had before that mode existed.
|
||||
private func bodyColumn(embedded: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
|
||||
titleRow
|
||||
@@ -360,9 +408,14 @@ struct CardWindowView: View {
|
||||
presentation: bodyPresentation,
|
||||
session: bodySession,
|
||||
isTaskToggleEnabled: isEditable,
|
||||
onToggleTask: onToggleTask
|
||||
onToggleTask: onToggleTask,
|
||||
scrolls: !embedded
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// `maxHeight` drops to `nil` while embedded: `sizeThatFits` (`CardBodySurface`) reports
|
||||
// the text's own height for the width it is given, and a modifier still claiming
|
||||
// `.infinity` of the outer scroll's unbounded proposal would ask for an undefined amount
|
||||
// of it instead.
|
||||
.frame(maxWidth: .infinity, maxHeight: embedded ? nil : .infinity)
|
||||
}
|
||||
// **Dirty-buffer-wins, applied on every snapshot** (05 ▸ Write rules): the session takes
|
||||
// disk's word for what the file says, and takes it into the editor only when the buffer has
|
||||
|
||||
Reference in New Issue
Block a user