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