Build lane chrome — title bar, badge, inline rename
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
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import CoreGraphics
|
||||
import Observation
|
||||
|
||||
/// Window-local state for an in-flight lane reorder — the drag surface being the whole title bar
|
||||
/// (03-board-ui.md § Lane, "no separate grip"). At most one runs per board window; `BoardView` owns
|
||||
/// it as `@State` and hands it to the lanes.
|
||||
///
|
||||
/// `LaneResizeSession`'s sibling, and deliberately much smaller. It holds only what the *pointer*
|
||||
/// contributes — which lane, how far it has travelled, and the centre it started from — because
|
||||
/// everything else the proposal needs is read fresh from the snapshot at render time
|
||||
/// (`LaneReorderMath.proposedIndex`). That split is 04-interactions.md ▸ Drag and drop's
|
||||
/// re-grounding rule made structural: "the frozen-at-drag-start inputs are the *dragged items'*
|
||||
/// sizes and the physical pointer only … the analytic resting zones recompute against each new
|
||||
/// snapshot", so a foreign lane add mid-drag cannot leave this session holding a stale board.
|
||||
///
|
||||
/// ### The click-versus-drag split
|
||||
///
|
||||
/// A plain click on the title bar selects the lane; only movement past `threshold` begins a
|
||||
/// reorder (04 ▸ Selection: "the drag surface engages only on movement — the click-vs-drag split
|
||||
/// cards already have"). One gesture recognises both, so a hesitant click can never start a drag
|
||||
/// and a drag can never also select.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LaneReorderSession {
|
||||
|
||||
/// The lane being dragged; `nil` when idle. Observed — flipping it drives the travelling lane's
|
||||
/// z-order and offset, and the siblings' reflow into the proposed order.
|
||||
private(set) var laneID: ItemID?
|
||||
|
||||
/// How far the pointer has travelled horizontally since the drag began. The *only* live input:
|
||||
/// vertical movement is ignored outright, since lanes reorder along one axis.
|
||||
private(set) var translation: CGFloat = 0
|
||||
|
||||
/// The dragged lane's resting centre at drag start, in strip coordinates — the origin
|
||||
/// `translation` is measured from, frozen exactly as 03-board-ui.md § Motion requires.
|
||||
@ObservationIgnored private(set) var startCentre: CGFloat = 0
|
||||
|
||||
/// How far the pointer must move before a click becomes a drag. Small enough that a deliberate
|
||||
/// drag feels immediate, large enough that the tremor in a click never reorders the board.
|
||||
static let threshold: CGFloat = 4
|
||||
|
||||
var isActive: Bool { laneID != nil }
|
||||
|
||||
func isDragging(_ id: ItemID) -> Bool { laneID == id }
|
||||
|
||||
/// The dragged lane's centre under the cursor: the frozen start plus the physical translation,
|
||||
/// and nothing measured.
|
||||
var centre: CGFloat { startCentre + translation }
|
||||
|
||||
/// Begins a reorder of `laneID`, freezing the centre its travel is measured from.
|
||||
func begin(laneID: ItemID, startCentre: CGFloat) {
|
||||
self.laneID = laneID
|
||||
self.startCentre = startCentre
|
||||
self.translation = 0
|
||||
}
|
||||
|
||||
func update(translation: CGFloat) {
|
||||
guard isActive else { return }
|
||||
self.translation = translation
|
||||
}
|
||||
|
||||
/// Ends the drag, handing the caller nothing: the *commit* needs the current snapshot's lane
|
||||
/// order, which `BoardView` has and this session deliberately does not. Idempotent, because a
|
||||
/// gesture can end after the lane it was carrying has already vanished.
|
||||
func end() {
|
||||
laneID = nil
|
||||
translation = 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user