The lane title bar becomes real: leading SF Symbol (hand-written names render leniently, unknown ones fall back to the level default), title or secondary untitled placeholder, a quiet count badge that counts exactly the cards the body renders (so the m5 search filter is followed by construction), and a new-card button. The whole bar is the reorder drag surface — no grip — with click-vs-movement splitting select from drag; a pure proposal function maps the drag to an insertion index and release commits through the Writer's same-parent degenerate reorder, compacting and retrying when midpoint precision runs out. Clicking never edits: inline rename is Return on the sole selected card or Board > Rename for either kind, a third transient editor beside the placeholder that tracks its target by UUID, commits on focus loss, discards silently when the target vanishes, and removes the title key on an empty commit. The new-card placeholder renders at last — the settled Cmd-N target rule (pure, tested) files it after the anchor card, at a selected lane's bottom, or into the last-active lane; Return commits and re-selects the lane, Cmd-Return also opens the card window, and a failed create discards the overlay. New Card / New Lane / Rename land in the menus with focused-editor and read-only validation; rename gets its own WriteOperation case in the banner vocabulary. 59 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
111 lines
5.0 KiB
Swift
111 lines
5.0 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])
|
|
}
|
|
|
|
/// `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: - 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 })
|
|
}
|
|
}
|