Files
lanework/Kanban/Storage/Ranks.swift
T
rzen bec75e4282 Realign code with the 2026-07-31 rulings
The trash sorts by modified descending — the arrival rank mint retires
(Ranks.isOrderedForTrash one comparator, loader + merged order agree;
the legacy deleted: migration stamps modified from the tombstone
timestamp where parseable; delete undo steps validate existence-only;
agent guide v8). Trash selection goes kind-blind — ranges, marquee,
Select All, and the successor walk sweep both kinds; the guard moves to
the exits (mixed-payload drop refusal, copy/cut validation). The copy
stamping preflight widens back to comment depth (load-scoped posture —
the board always loads, the gesture refuses whole). Fixes a latent
no-op: trashed-lane drag restore never fired (DragSession.beginLanes
hard-coded the board container).

2403 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 18:35:07 -04:00

220 lines
11 KiB
Swift

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>) -> 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>) -> 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<T>(
_ 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<T>(
_ 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<T>(
_ 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<T>(
_ 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 })
}
}