Files
lanework/Kanban/UI/Card/CardCommentsLayout.swift
rzen fe3ffac48e Comments, phase 2 — the pane, the composer, and the inline session
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
2026-07-30 20:19:52 -04:00

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
}
}