Files
lanework/Kanban/UI/Card/CardCommentsLayout.swift
T
rzen 6bf3308915 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
2026-08-09 12:30:41 -04:00

234 lines
12 KiB
Swift

import CoreGraphics
import Foundation
// MARK: - Where the comments pane mounts
/// **Body and comments, side by side or stacked** — the layout option, as a value
/// (05-card-window.md ▸ Composition, ruled 2026-07-29: "side-by-side is the default; **View ▸
/// Comments Beside Body** unchecked stacks them — body pane above, comments pane below at a fixed
/// ≈3:2 split, each keeping its own scroll — for narrow displays. App-wide, persisted").
///
/// It is an enum rather than the `Bool` the menu row stores because the two mounts differ in three
/// arithmetic facts — the axis, the split, and whether the window's minimum width grows — and a
/// `Bool` at each of those three sites is three chances to read it backwards.
///
/// **The panes are identical in both mounts.** Nothing below describes a pane; it describes the frame
/// one is given. That is the componentization the design asks for stated as code: `CardCommentsPane`
/// has no idea which of these it is inside, and neither has the body column.
public enum CommentsMount: Sendable, Equatable {
/// Comments beside the body, sharing the window's width. The default.
case beside
/// Comments under the body, sharing the window's height at ≈3:2.
case stacked
/// The menu row's bit, read the one way — checked means beside.
public init(besideBody: Bool) {
self = besideBody ? .beside : .stacked
}
/// **The stacked split: three parts body to two parts comments** — 05's "≈3:2", written once.
///
/// A fraction rather than a point height so the split survives every window size and every system
/// text size, and a *pure* one so "the body keeps three fifths" is a fact a suite asserts rather
/// than something checked by eye at one window height.
public static let stackedBodyFraction: CGFloat = 3.0 / 5.0
/// How tall the body pane is in a window `height` tall — `height` itself when the panes are side
/// by side, since then the body owns the full column.
///
/// Clamped at zero: a window mid-animation can propose a negative height, and a frame with one
/// would be a layout error rather than a small pane.
public func bodyHeight(in height: CGFloat) -> CGFloat {
switch self {
case .beside: max(0, height)
case .stacked: max(0, height * Self.stackedBodyFraction)
}
}
/// The comments pane's height, the remainder — so the two always add up to the window and the
/// divider between them never has a gap or an overlap to account for.
public func commentsHeight(in height: CGFloat) -> CGFloat {
max(0, height) - bodyHeight(in: height)
}
/// **Whether the window's minimum width grows** — "the window's minimum width grows only while
/// the column is shown side-by-side" (05 ▸ Composition).
///
/// Stacked, the comments pane takes the width the body already had, so a narrow display keeps the
/// minimum it has always had. That is the whole reason the option exists.
public var widensWindow: Bool { self == .beside }
/// **Whether Preview shows the stacked mount's continuous, single-scroll arrangement** — title,
/// body and the comment thread stacked directly atop each other in one document, rather than the
/// fixed ≈3:2 split with each pane keeping its own scroll.
///
/// Pure and mode-driven, so "continuous is Preview-only; Edit keeps the split; beside is
/// untouched" is a fact a test can hold rather than something read off a running window
/// (`CardWindowView.contentPanes` is the one caller). Edit needs its own stable scroll — a text
/// editor's caret, selection and undo session cannot share a document scroll with the thread
/// beneath it and stay usable — and the beside mount never had a split to begin with, so neither
/// case answers `true` regardless of `mode`.
public func showsContinuousDocument(mode: CardBodyMode) -> Bool {
self == .stacked && mode == .preview
}
}
// MARK: - Which way the thread runs
/// **The thread's sort direction** — "chronological ascending by default, flippable to newest-first
/// (app-wide, persisted)" (05-card-window.md ▸ The comments column).
///
/// The *order* itself is storage's (`CommentThread.sorted`, `created` ascending with the undated
/// after the dated and a canonical folder-name tie-break) and is not re-derived here: descending is
/// that order reversed, so a tie between two undated comments breaks the same way in both directions
/// rather than two sort predicates agreeing by luck.
public enum CommentSortDirection: Sendable, Equatable, CaseIterable {
/// Oldest first — chronology as it happened, and the default.
case ascending
/// Newest first.
case descending
/// The menu/header control's bit, read the one way.
public init(newestFirst: Bool) {
self = newestFirst ? .descending : .ascending
}
public var isNewestFirst: Bool { self == .descending }
/// Applies the direction to a thread the loader already sorted.
///
/// **A reverse, never a re-sort**: see the type's note — the loader's predicate is the only one
/// in the app that decides what "before" means for two comments.
public func apply(to comments: [Comment]) -> [Comment] {
self == .ascending ? comments : comments.reversed()
}
/// **Whether the composer sits above the thread** — "The composer sits at the thread's newest end
/// (bottom ascending, top descending)" (05 ▸ The comments column).
///
/// One fact, derived once, because the composer's placement and the pane's opening scroll target
/// are the same sentence read twice ("the window opens scrolled to it") and a window that opened
/// at the wrong end would be wrong only for descending users.
public var placesComposerFirst: Bool { self == .descending }
/// What the header control says it will do — the help text and the accessibility label, which are
/// the same string and must stay so.
public var controlLabel: String {
self == .ascending ? "Oldest First" : "Newest First"
}
}
// MARK: - The header's count
/// The comments pane's small-caps header line — **"Comments · 3"** (05-card-window.md ▸ The comments
/// column: "The section header carries the count").
///
/// Pure and separate from the view for the reason every count line in this app is: the empty case is
/// the one that gets written wrong, and 05 is explicit that a comment-less card still shows the pane
/// ("the empty thread and the composer — the invitation is the point"), so the zero has to render as
/// a count rather than as an absence.
public enum CommentsHeader {
public static func title(count: Int) -> String {
"Comments · \(count)"
}
}
// MARK: - One comment's author line
/// The line above a comment's body: **who says they wrote it, when, and whether it has been edited**
/// (05-card-window.md ▸ The comments column: "an author line (self-reported `author`, unattributed
/// when absent; timestamp; '· edited' when `modified` differs from `created`)").
///
/// ### Absent means absent
///
/// "Missing renders unattributed" (`Comment.author`) — and *unattributed* is the absence of a name,
/// not the word "unattributed" drawn in its place. A placeholder there would be this app inventing an
/// identity for a file that deliberately carries none, which is the same reason there are no avatars.
/// So a comment with no `author` renders its timestamp alone, and one with neither renders nothing at
/// all rather than an empty row of separators.
public enum CommentAuthorLine {
/// The separator every segment of this window's quiet lines uses — the card's created/modified
/// line's, shared so the two read as one family.
private static let separator = " · "
/// Composes the line, or `nil` when there is nothing to say.
///
/// - Parameters:
/// - author: the self-reported `author`, already unwrapped. An **empty** string is treated as
/// absent: the Writer never writes `author: ""` (it omits the key instead), so one on disk is
/// a hand edit, and rendering a blank name with a separator beside it would be noise.
/// - timestamp: the already-formatted `created`, or `nil` where the key is missing or
/// unreadable (the coerce tier — a comment with no date still renders).
/// - isEdited: `Comment.isEdited`.
public static func text(author: String?, timestamp: String?, isEdited: Bool) -> String? {
var parts: [String] = []
if let author, !author.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
parts.append(author)
}
if let timestamp, !timestamp.isEmpty {
parts.append(timestamp)
}
// "· edited" hangs off something. On a comment with neither a name nor a date there is no
// line to hang it from, and a bare "edited" would be a row that says only that.
guard !parts.isEmpty else { return nil }
if isEdited {
parts.append("edited")
}
return parts.joined(separator: separator)
}
}
// MARK: - The drop carve-out
/// **Which folder a file dropped in this window lands in** (05-card-window.md ▸ Attachments, ruled
/// 2026-07-29: "One carve-out by hover target … a file dropped **within the comment composer's
/// bounds** imports to the draft's `attachments/`, and within an **inline comment edit session's
/// bounds** to that comment's — the window-wide card default covers everywhere else").
///
/// ### Why this is a value and not three `.onDrop`s trusted to nest correctly
///
/// SwiftUI does dispatch a drop to the innermost target, and the implementation leans on exactly
/// that — the composer and the inline editor each carry their own drop delegate inside the
/// window-wide one. But *what the rule is* and *whether the nesting expresses it* are two questions,
/// and only the first one is checkable without a running window. This enum is the first question's
/// answer; the modifiers are the second's.
///
/// ### The precedence, and why it is stated at all
///
/// An inline edit session opens **over a comment row**, and the composer is a separate surface at the
/// thread's newest end, so in practice the two never overlap and the order is moot. It is fixed
/// anyway — the inline session wins — because the case where it stops being moot is a layout change,
/// and a layout change should not be able to silently move a user's files into the wrong folder.
public enum CommentDropCarveOut {
/// What the pointer is over, as the window knows it.
///
/// Deliberately two independent facts rather than one enum: each surface answers only for itself
/// (a drop delegate knows its own bounds and nothing else), and the arbitration is this type's.
public struct Hover: Sendable, Equatable {
public var isOverComposer: Bool
public var inlineEdit: ItemID?
public init(isOverComposer: Bool = false, inlineEdit: ItemID? = nil) {
self.isOverComposer = isOverComposer
self.inlineEdit = inlineEdit
}
}
/// Where the files go.
public enum Landing: Sendable, Equatable {
/// The window-wide default: the **card**'s `attachments/` (`CardWindowDropDelegate`).
case card
/// One of the two authoring surfaces.
case comment(CommentTarget)
}
/// The rule, in one expression.
public static func landing(for hover: Hover) -> Landing {
if let editing = hover.inlineEdit { return .comment(.comment(editing)) }
if hover.isOverComposer { return .comment(.draft) }
return .card
}
}