The card window recomposes into three componentized panes (body, comments, attributes) with two mounts — beside or body-over-comments at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is one persisted app-wide bit, no content-derived auto-show; File ▸ Add Comment flips it on and focuses the composer. The thread renders author lines, edited markers, card-subset Markdown bodies, and read-only Quick Look chips under a count header with the sort- direction control. The composer edits comments/.draft/ on the slow cadence (blur, close, quit, ~30s interval), Escape only moves focus, ⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start bytes, close flushes. File drops within either authoring surface carve out of the window-wide card default into that surface's attachments/; paperclips cover the no-drag path. Close flush runs inline flush, then draft save, then the comments/.trash purge; open sweeps crash residue. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
220 lines
11 KiB
Swift
220 lines
11 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 }
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|