import Foundation /// Pure gapped-fractional-ordering math: append, insert, midpoint, and the /// renumber target, plus the display-order tie-break rule shared by the /// loader and the writer. No filesystem or model dependency — see /// DESIGN/01-storage-format.md § Ordering, Deletion. enum Ranks: Sendable { /// Gap between successive ranks on append/head-insert, and the multiple /// used by `renumbered(count:)`. private static let gap: Double = 1024 // MARK: - Append / insert /// Rank for a new item appended after all visible siblings. /// An empty lane's first item lands at `1024` (the board convention). static func append(toVisible orders: some Sequence) -> Double { (orders.max() ?? 0) + gap } /// Rank for a new item inserted before all visible siblings. /// An empty lane's first item lands at `1024` (the board convention). static func insertAtHead(ofVisible orders: some Sequence) -> Double { guard let minOrder = orders.min() else { return gap } return minOrder - gap } /// Rank strictly between `a` and `b`, or `nil` if no `Double` is /// representable between them — including when `a == b` (a duplicate /// order, the tie case). Never returns a value ≤ min(a, b) or /// ≥ max(a, b). static func midpoint(between a: Double, and b: Double) -> Double? { let lower = min(a, b) let upper = max(a, b) guard lower < upper else { return nil } let mid = lower + (upper - lower) / 2 guard mid > lower, mid < upper else { return nil } return mid } /// The rank an item takes when it lands at display position `index` among `orders` — the /// visible siblings it is joining, **in display order and with the item itself already /// excluded**. /// /// One function for the three cases every insertion gesture has (a lane drag's release, a /// drop between two cards, ⌘N's after-the-anchor position), so no call site re-derives which /// of `insertAtHead`/`midpoint`/`append` its position calls for: /// /// - at or before the head → `insertAtHead(ofVisible:)`; /// - at or past the end (an empty `orders` included) → `append(toVisible:)`; /// - between two siblings → their `midpoint`. /// /// **`nil` means the gap is exhausted, not that the insertion is illegal**: `midpoint` returns /// no value when two neighbours are adjacent `Double`s or share an order (the duplicate-order /// tie). That is the renumber trigger (01-storage-format.md § Ordering) and the caller's cue to /// compact and ask again — never something to paper over with an arbitrary rank, which would /// silently reorder the board. static func insertionRank(amongVisible orders: [Double], at index: Int) -> Double? { if orders.isEmpty || index >= orders.count { return append(toVisible: orders) } if index <= 0 { return insertAtHead(ofVisible: orders) } return midpoint(between: orders[index - 1], and: orders[index]) } /// The `count` ranks a **contiguous run** takes when it lands at display position `index` /// among `orders` — `insertionRank(amongVisible:at:)` for a multi-drag, whose whole set inserts /// at one spot in preserved order (04-interactions.md ▸ Drag and drop, DRAG-REORDER.md § /// Multi-drag). /// /// `orders` is the visible siblings **in display order with the run itself already excluded** — /// the resting layout's convention, the same one the geometry's index is counted in. /// /// The three cases mirror the single-rank twin, spread over `count` values: /// /// - at or before the head → `count` whole gaps *below* the first sibling, ascending; /// - at or past the end (an empty `orders` included) → `count` whole gaps above the last; /// - between two siblings → `count` evenly spaced points strictly inside their interval. /// /// **`nil` means the gap is exhausted, not that the insertion is illegal** — the interior case /// fails when the two neighbours are close enough that `count` distinct, strictly increasing /// `Double`s do not fit between them (adjacent doubles, or the duplicate-order tie). That is /// the renumber trigger (01-storage-format.md § Ordering) and the caller's cue to compact and /// ask again, exactly as an exhausted midpoint is everywhere else. static func insertionRanks(amongVisible orders: [Double], at index: Int, count: Int) -> [Double]? { guard count > 0 else { return [] } if orders.isEmpty || index >= orders.count { let base = orders.max() ?? 0 return (1...count).map { base + gap * Double($0) } } if index <= 0 { let base = orders.min() ?? 0 // Ascending, and every value below `base`: the deepest is `count` gaps down. return (1...count).map { base - gap * Double(count - $0 + 1) } } let lower = orders[index - 1] let upper = orders[index] guard lower < upper else { return nil } let step = (upper - lower) / Double(count + 1) var ranks: [Double] = [] var previous = lower for position in 1...count { let rank = lower + step * Double(position) // Every rank must sit strictly inside the interval *and* strictly above the last one: // at the precision floor the arithmetic silently collapses onto a neighbour, and a // duplicate rank would hand display order to the folder-name tie-break. guard rank > previous, rank < upper else { return nil } ranks.append(rank) previous = rank } return ranks } /// `count` fresh ranks, whole multiples of 1024 in ascending order /// (1024, 2048, …) — the renumber target when midpoint precision is /// exhausted. Deterministic by construction; the writer applies these, /// in order, to the current visible siblings in display order. static func renumbered(count: Int) -> [Double] { guard count > 0 else { return [] } return (1...count).map { Double($0) * gap } } // MARK: - Display order /// Ascending display order: primary key `order`, ties broken by folder /// name (lexicographic) for deterministic rendering. Shared by the /// loader and the writer so both apply the same tie-break rule. static func isOrderedForDisplay( _ lhs: T, before rhs: T, order: (T) -> Double, name: (T) -> String ) -> Bool { let lhsOrder = order(lhs) let rhsOrder = order(rhs) return lhsOrder != rhsOrder ? lhsOrder < rhsOrder : name(lhs) < name(rhs) } /// Sorts siblings into display order — ascending `order`, ties broken by /// folder name. static func sortedForDisplay( _ items: [T], order: (T) -> Double, name: (T) -> String ) -> [T] { items.sorted { isOrderedForDisplay($0, before: $1, order: order, name: name) } } // MARK: - The trash's own order /// The trash's order: **`modified` descending**, ties broken by title /// (case-insensitive), then folder name (01-storage-format.md § Deletion, /// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md § /// Trash: "the trash sorts by `modified` descending … Ties break by title /// (case-insensitive), then folder name — the deterministic tail"). /// /// **`order` is not consulted at all in here**, which is the whole of the /// ruling: a delete is a pure folder move plus the stamp, and the item's /// `order` key rides along untouched for its eventual restore. The trash is /// the one container in the app whose sequence is not a rank sequence. /// /// **An undated entry sorts after every dated one** — the comment thread's /// own rule for a missing `created` ("ties and missing/malformed … sort /// after dated siblings"), read one container over: a stamp the app always /// writes is missing only on a hand-made or foreign entry, and a missing /// value must never outrank a real deletion. The tail then separates them /// exactly as it separates two equal stamps. /// /// Titles compare with `localizedCaseInsensitiveCompare`, the same /// comparison the rest of the app's user-facing sorting uses, and an /// untitled entry compares as the empty string — it sorts first among its /// stamp-mates, which is deterministic and is all the tail owes. static func isOrderedForTrash( _ lhs: T, before rhs: T, modified: (T) -> Date?, title: (T) -> String?, name: (T) -> String ) -> Bool { let lhsModified = modified(lhs) let rhsModified = modified(rhs) if lhsModified != rhsModified { // Newest first; an absent stamp is older than every present one. switch (lhsModified, rhsModified) { case let (.some(left), .some(right)): return left > right case (.some, .none): return true case (.none, .some): return false case (.none, .none): break } } let lhsTitle = title(lhs) ?? "" let rhsTitle = title(rhs) ?? "" let comparison = lhsTitle.localizedCaseInsensitiveCompare(rhsTitle) guard comparison == .orderedSame else { return comparison == .orderedAscending } return name(lhs) < name(rhs) } /// Sorts the trash's entries into column order — `isOrderedForTrash`'s rule, /// applied by the loader to each kind's array and by `BoardModel.trashEntries` /// to the merged sequence, so the two can never disagree about what "the row /// below this one" is (03-board-ui.md § Trash: "The merged order is one /// derivation"). static func sortedForTrash( _ items: [T], modified: (T) -> Date?, title: (T) -> String?, name: (T) -> String ) -> [T] { items.sorted { isOrderedForTrash($0, before: $1, modified: modified, title: title, name: name) } } // MARK: - Tombstone exclusion /// `append(toVisible:)`, ignoring tombstoned siblings. Tombstones are /// inert to ordering — appends operate on visible siblings only. static func append(toVisible items: some Sequence<(order: Double, isDeleted: Bool)>) -> Double { append(toVisible: items.filter { !$0.isDeleted }.map { $0.order }) } /// `insertAtHead(ofVisible:)`, ignoring tombstoned siblings. Tombstones /// are inert to ordering — inserts operate on visible siblings only. static func insertAtHead(ofVisible items: some Sequence<(order: Double, isDeleted: Bool)>) -> Double { insertAtHead(ofVisible: items.filter { !$0.isDeleted }.map { $0.order }) } }