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:
2026-07-27 08:52:24 -04:00
parent ff3ba298f0
commit b35566e0fe
21 changed files with 2855 additions and 79 deletions
+12 -1
View File
@@ -66,7 +66,18 @@ struct BoardWindowHost: View {
// The window is handed to the board as a closure, not a value: `WindowAccessor`
// attaches after this body first runs, and the lane-resize drag needs the *live*
// window to grow at its right edge (03-board-ui.md § Lane).
BoardView(store: store, window: { windowController.window })
//
// `openCard` is the host's too, for a different reason: a card window's identity is
// `(board, card)` and only this view holds the board half. `openWindow(value:)` with
// a ref that already has a window focuses it, so "at most one card window per card
// (reopen focuses)" needs no bookkeeping here (02-architecture.md § Windows).
BoardView(
store: store,
window: { windowController.window },
openCard: { cardID in
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
}
)
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`).
.focusedSceneValue(\.boardStore, store)
+15 -3
View File
@@ -107,17 +107,29 @@ struct KanbanApp: App {
/// changing one silently breaks every remap of it.
@CommandsBuilder
private var menuCommands: some Commands {
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board,
// still owed), Open. The creation pair validates against the frontmost board through the
// focus system, so both are simply absent-of-effect when no board is in front.
CommandGroup(after: .newItem) {
BoardCreationCommands()
Divider()
Button("Open…") {
appModel.presentOpenPanel()
}
.keyboardShortcut("o", modifiers: .command)
}
// The Board menu (11-command-nexus.md). Its items act on the frontmost board window, which
// they reach through the focus system rather than through the app model see
// `LaneWidthCommands`, which also owns their validation.
// The Board menu (11-command-nexus.md), in its inventoried order Rename precedes the
// width pair, with Open Card, Style and the Move items still owed. Its items act on the
// frontmost board window, which they reach through the focus system rather than through the
// app model see `BoardCommands.swift`, which also owns their validation.
CommandMenu("Board") {
BoardRenameCommand()
Divider()
LaneWidthCommands()
}
+5
View File
@@ -449,6 +449,11 @@ public final class BannerCenter {
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
case let .resize(title):
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
case let .rename(title):
// The title here is the item's name *before* the edit the one the user is still
// looking at which is what makes "Couldn't rename 'Fix login'" (02's own example
// sentence) identify the right row rather than a name that never landed.
if let title { "Couldn't rename '\(title)'" } else { "Couldn't rename the item" }
case let .importAttachment(filename):
"Couldn't import '\(filename)'"
case .listAttachments:
+271 -3
View File
@@ -645,32 +645,300 @@ public final class BoardStore {
}
}
// MARK: - Creation
/// Creates a lane at the board's right end File New Lane N (11-command-nexus.md).
///
/// **Untitled, and deliberately with no inline editor.** 03-board-ui.md gives lane titles one
/// editing surface "Inline rename on the header" and 04-interactions.md gives that surface
/// one entry point, Board Rename, "since Return on a lane creates a card". Nothing in either
/// doc opens an editor *at creation*, so a new lane appears with the untitled placeholder and
/// the user renames it if they want a name. Titles are optional at every level; a lane with no
/// `title` key is a legitimate resting state, not a half-finished one.
///
/// Like `setLaneWidth`, the rethrow is swallowed: `performWrite` has already posted the banner,
/// and a menu item has no second thing to do about a failure.
public func createLane() {
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.createLane(inBoard: root, title: nil)
}
}
// MARK: - The new-card placeholder's commit
/// Turns the open placeholder into a real card the write half of 02-architecture.md §
/// Layering's one named exception to the one-way flow.
///
/// The five outcomes, all settled:
///
/// - **No placeholder, or one already committed** nothing to do. (Idempotence matters: Return
/// commits, and the field's focus-loss handler fires immediately afterwards.)
/// - **An empty title discards it** "creating-then-abandoning never leaves an empty card
/// behind" (04-interactions.md Grammar). Whitespace counts as empty: a title of three
/// spaces is a slip, not a deliberate untitled card.
/// - **A vanished lane discards it** the anchor is gone, so there is nowhere to file the
/// card; the reload that removed the lane is the authority.
/// - **A failed create discards it too** (settled, 02 § Layering): "the overlay never waits for
/// a card that cannot arrive". The failure is already the banner's.
/// - **A successful create hands off**: the overlay flips to `.awaitingArrival` and stands until
/// the watcher round-trips the real card, so the user never sees a hole where they just typed.
///
/// - Returns: the created card's id, or `nil` on any of the discard paths which is what the
/// call site needs to know whether it has a card window to open.
@discardableResult
public func commitPlaceholder() -> ItemID? {
guard let placeholder = transient.newCardPlaceholder, placeholder.phase == .editing else { return nil }
let title = placeholder.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty,
let lane = snapshot.lanes.first(where: { $0.id == placeholder.laneID && !$0.isDeleted })
else {
transient.discardPlaceholder()
return nil
}
let laneFolder = rootURL.appendingPathComponent(placeholder.laneID.rawValue)
let visible = lane.cards.filter { !$0.isDeleted }
// `nil` means "append", which is `createCard`'s own default so the anchored case is the
// only one that needs a rank at all.
let position = Self.insertionIndex(after: placeholder.anchorCardID, among: visible)
let created = try? performWrite { () throws(BoardWriteError) -> ItemID in
let id = try BoardWriter.createCard(inLane: laneFolder, title: title)
guard let position else { return id }
// The rank is computed here rather than passed to `createCard` because the create's
// contract is "append after the visible siblings" and widening it would give every
// caller a position to think about. The reposition rides the Writer's own same-parent
// degenerate reorder "a move whose destination is the item's current parent degrades
// to a plain reorder" inside the *same* `performWrite`, so the pair rounds back as
// one app-mediated reload rather than showing the card at the bottom for a frame.
var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position)
if rank == nil {
// Midpoint precision exhausted between the anchor and its neighbour
// (01-storage-format.md § Ordering). Compact, then place against the fresh ranks:
// the new card is not among the renumbered siblings it was appended past them
// so the compacted ladder lines up one-for-one with `visible`.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position)
}
guard let rank else { return id }
_ = try BoardWriter.moveItem(
at: laneFolder.appendingPathComponent(id.rawValue),
toParent: laneFolder,
sourceBoardRoot: rootURL,
destinationBoardRoot: rootURL,
order: rank
)
return id
}
guard let created else {
transient.discardPlaceholder()
return nil
}
transient.commitPlaceholder(expecting: created)
return created
}
/// The display position a new card takes, or `nil` for "append at the bottom".
///
/// An anchor that is not among `visible` degrades to `nil` rather than failing: the card the
/// N target rule named was deleted or moved away mid-typing, and the lane the anchor that
/// actually matters is still there. Appending is the honest fallback; refusing to create
/// would punish the user for someone else's edit.
nonisolated static func insertionIndex(after anchor: ItemID?, among visible: [Card]) -> Int? {
guard let anchor, let index = visible.firstIndex(where: { $0.id == anchor }) else { return nil }
// Already last: "immediately after it" and "at the bottom" are the same position, and
// append needs no rank of its own.
return index + 1 < visible.count ? index + 1 : nil
}
// MARK: - Inline rename
/// Writes the open rename editor's draft the third inline editor's commit
/// (04-interactions.md Grammar), reached by Return **and** by focus loss ("a rename commits
/// the deliberate exception being the placeholder, because nothing exists on disk yet").
///
/// Four rules, all from 04 and 03:
///
/// - **The editor closes first, unconditionally.** Every path below ends with it gone, and
/// retiring it up front is what makes this idempotent Return commits and the field's
/// focus-loss handler fires an instant later against no editor at all.
/// - **A vanished target writes nothing, silently.** "A target that is tombstoned, deleted, or
/// gone at commit time discards the editor and its keystrokes silently nothing is ever
/// written into a vanished folder, and no partial `index.md` can resurrect deleted data."
/// Liveness is effective a card under a tombstoned lane is vanished too.
/// - **An empty commit removes the `title` key** (03-board-ui.md § Card face; 04 Selection:
/// "Committing an empty rename on an existing item removes its `title` key"), rather than
/// writing `title: ""` titles are optional, and the face shows the untitled placeholder.
/// - **An unchanged title writes nothing.** `setLaneWidth`'s rule, for the same reason: an
/// editor opened and dismissed with Return must not stamp `modified` or mint a commit.
///
/// The folder is re-derived from the *current* snapshot, which is what makes a foreign move
/// mid-rename invisible: the editor follows the UUID, and the write lands wherever the item is
/// now.
public func commitRename() {
guard let editor = transient.renameEditor else { return }
transient.discardRename()
guard let target = Self.liveItem(editor.targetID, in: snapshot) else { return }
let typed = editor.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let newTitle: String? = typed.isEmpty ? nil : typed
guard newTitle != target.title else { return }
var folder = rootURL.appendingPathComponent(target.laneID.rawValue)
if let cardID = target.cardID {
folder.append(component: cardID.rawValue)
}
try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so the
// banner names the item by the title it still has rather than the one that failed to
// land (see `WriteOperation.rename`).
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newTitle {
document.set(FrontmatterKeys.title, to: .string(newTitle))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
}
/// Where a live item lives and what it is currently called, or `nil` when the id names nothing
/// the board renders.
///
/// **Effective liveness, ancestor-walked** the same rule `CardWindowHost.cardWindowFate`
/// applies to a card window and `ItemReferenceSet` applies to the selection: a card under a
/// tombstoned lane renders nowhere, so it is as gone as a deleted one. The path is returned as
/// its two identity components rather than as a URL so the caller builds it off the store's
/// *current* `rootURL`, which a mid-session folder rename may have moved.
nonisolated static func liveItem(
_ id: ItemID,
in snapshot: BoardModel
) -> (laneID: ItemID, cardID: ItemID?, title: String?)? {
for lane in snapshot.lanes where !lane.isDeleted {
if lane.id == id {
return (laneID: lane.id, cardID: nil, title: lane.title.value)
}
if let card = lane.cards.first(where: { $0.id == id && !$0.isDeleted }) {
return (laneID: lane.id, cardID: card.id, title: card.title.value)
}
}
return nil
}
// MARK: - Lane reorder
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
/// counted **with the dragged lane itself removed** which is the index
/// `LaneReorderMath.proposedIndex` produces.
///
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
/// the same URL, so `moveItem` takes its same-parent degenerate-reorder path and rewrites
/// exactly one file the moved lane's `order`.
///
/// **A drag that ends where it started writes nothing**: `index == from` re-inserts the lane in
/// its own slot, and a no-op must not stamp `modified` or mint a commit the resize drag's
/// rule, and for the same reason.
public func moveLane(_ id: ItemID, toIndex index: Int) {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard let from = lanes.firstIndex(where: { $0.id == id }) else { return }
var remaining = lanes
remaining.remove(at: from)
let target = min(max(0, index), remaining.count)
guard target != from else { return }
let root = rootURL
let folder = root.appendingPathComponent(id.rawValue)
try? performWrite { () throws(BoardWriteError) -> Void in
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
if rank == nil {
// Compact and place again. Unlike the card case the dragged lane *is* among the
// renumbered children it is a real folder on disk so its fresh rank is dropped
// from the ladder before the neighbours are consulted.
try BoardWriter.renumberVisibleChildren(of: root)
var compacted = Ranks.renumbered(count: lanes.count)
compacted.remove(at: from)
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
}
guard let rank else { return }
_ = try BoardWriter.moveItem(
at: folder,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
}
// MARK: - Selection (delegated)
// The three thin pass-throughs to `transient`, and the only ones.
// The thin pass-throughs to `transient`, and the only ones.
//
// **Conveniences, not a second home.** The selection is the transient state every command site
// touches menu validation, , paste anchoring, Select All and `store.selection` reads better
// at each of them than `store.transient.selection` while meaning exactly the same thing. Nothing
// is stored here: `selection` is computed and the two mutators forward, so there is no second
// copy to go stale. Drag membership, the pending cut, the query and the placeholder get no such
// copy to go stale. Drag membership, the pending cut, the query and the editors get no such
// shortcuts they have one or two call sites each, and a delegate per field would be the
// grab-bag reassembling itself on this class.
//
// `isEditingInline` earns one for the selection's reason and no other: **every** board-mutating
// menu item validates against it (04-interactions.md's focused-editor rule), and a rule read
// that often should read as one word.
/// The board's selection, re-resolved against every snapshot this store applies
/// `TransientBoardState.selection` under a shorter name.
public var selection: ItemReferenceSet { transient.selection }
/// Replaces the selection `TransientBoardState.select(_:liveness:)`, which owns the semantics.
/// Whether an inline title editor is open `TransientBoardState.isEditingInline`, which owns
/// what it means and why every mutating command reads it.
public var isEditingInline: Bool { transient.isEditingInline }
/// Replaces the selection, and **records the lane it lands in** as the last-active one.
///
/// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it
/// takes a snapshot to answer "which lane is that". 04-interactions.md's N target rule calls
/// for "the lane that most recently held selection or a creation", and a *card* selection is
/// its lane holding selection just as much as the lane's own header click is so both are
/// noted here, and creation notes itself in `beginPlaceholder`.
public func select(_ ids: Set<ItemID>, liveness: Liveness) {
transient.select(ids, liveness: liveness)
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar).
///
/// The last-active lane deliberately survives: it is a high-water mark of where the user has
/// been working, and N after a deselect is exactly the case it exists to answer.
public func clearSelection() {
transient.clearSelection()
}
/// The lane a selection sits in, or `nil` when it names no single one a live lane selects
/// itself; live cards select their lane, but only when they all share one (a cross-lane
/// selection has no single home to remember).
nonisolated static func lane(holding ids: Set<ItemID>, in snapshot: BoardModel) -> ItemID? {
guard !ids.isEmpty else { return nil }
var found: ItemID?
for lane in snapshot.lanes where !lane.isDeleted {
let names = ids.contains(lane.id) || lane.cards.contains { !$0.isDeleted && ids.contains($0.id) }
guard names else { continue }
guard found == nil else { return nil }
found = lane.id
}
return found
}
// MARK: - Quiescence
/// Suspends until no reload is running and none is owed.
+187 -10
View File
@@ -155,18 +155,81 @@ public struct NewCardPlaceholder: Sendable, Equatable {
/// has before its title commits.
public var laneID: ItemID
/// The card the new one is being born **immediately after**, or `nil` for the lane's bottom.
///
/// It exists because 04-interactions.md's N target rule is a *position*, not just a lane:
/// "with a card selected, the new card is created in that card's lane, immediately after it
/// (paste-anchor consistency)". Every other entry point Return on a lane, the header button,
/// a double-click on empty space, N with nothing selected appends at the bottom and passes
/// `nil`.
///
/// **A position, deliberately not a rank.** The `order` is computed at *commit* time against
/// the snapshot as it is then (`BoardStore.commitPlaceholder`), never frozen when the editor
/// opened: an agent filing a card into the gap mid-typing must move the new card, not be
/// overwritten by a stale midpoint. An anchor that has vanished by commit time degrades to the
/// lane's bottom rather than failing the lane is the anchor that matters, and the card being
/// created is the user's, not the vanished neighbour's.
public var anchorCardID: ItemID?
/// What the user has typed so far. Lives here and nowhere else: there is no file to hold it.
public var draftTitle: String
public var phase: Phase
public init(laneID: ItemID, draftTitle: String = "", phase: Phase = .editing) {
public init(
laneID: ItemID,
anchorCardID: ItemID? = nil,
draftTitle: String = "",
phase: Phase = .editing
) {
self.laneID = laneID
self.anchorCardID = anchorCardID
self.draftTitle = draftTitle
self.phase = phase
}
}
// MARK: - RenameEditor
/// The **third inline editor** (04-interactions.md Grammar): a card's or a lane's title being
/// edited in place, on the card face or in the lane header.
///
/// It is `NewCardPlaceholder`'s sibling in shape and its opposite in almost every rule, and the
/// contrast is worth stating once:
///
/// | | `NewCardPlaceholder` | `RenameEditor` |
/// |---|---|---|
/// | References | a *lane* (no identity yet) | an **item**, by UUID |
/// | Click-away | discards nothing is on disk | **commits** the item is real |
/// | Empty commit | discards the whole draft | removes the `title` key |
/// | A reload can | drop it, or hand it off | only drop it |
///
/// **Tracked by UUID, which makes a foreign move invisible.** "A foreign *move* mid-rename is
/// invisible the editor follows the UUID and the commit writes the title wherever the card now
/// lives"; the commit re-derives the folder from the current snapshot, so a card an agent filed
/// into another lane mid-typing is still renamed correctly.
///
/// There is no phase enum. The placeholder needs one because it outlives its own commit (the
/// overlay stands in for a card that has not arrived yet); a rename has nothing to stand in for
/// the item is already on screen, and the commit's round trip simply updates it.
public struct RenameEditor: Sendable, Equatable {
/// The card or lane being renamed. Deliberately untyped as to *kind*: the commit derives the
/// folder by finding the id in the snapshot, and every rule here the vanish discard, the
/// UUID tracking, the empty-commit removal reads identically for both levels.
public var targetID: ItemID
/// What the user has typed. **Seeded from the current title** when the editor opens (an
/// untitled item seeds empty, since "Untitled" is a rendering, not a value
/// 03-board-ui.md § Card face), and thereafter the only place the draft exists.
public var draftTitle: String
public init(targetID: ItemID, draftTitle: String = "") {
self.targetID = targetID
self.draftTitle = draftTitle
}
}
// MARK: - TransientBoardState
/// Everything a board's windows share that is **not on disk** one per `BoardStore`, created with
@@ -186,12 +249,18 @@ public struct NewCardPlaceholder: Sendable, Equatable {
/// reload lands and a card edited to no longer match animates out. A stored result set would be
/// a second, staler answer to a question the snapshot can always answer, and would need a
/// re-resolution rule of its own which is exactly the accretion this type exists to stop.
/// 3. **The overlay** `newCardPlaceholder`, anchored to a lane rather than to items, discarded
/// when the lane it is anchored to goes away and handed off when the real card arrives.
/// 3. **The inline editors** `newCardPlaceholder`, anchored to a lane rather than to items,
/// discarded when the lane it is anchored to goes away and handed off when the real card
/// arrives; and `renameEditor`, anchored to an *item* and discarded when that item vanishes.
/// Two editors, one at a time: they share the app's single keyboard focus, so beginning either
/// ends the other.
///
/// The remainder is plain per-open values: `isTrashVisible` is hidden on every open and **never
/// persisted** visiting the trash is an errand, not a layout choice. It needs no reset logic
/// because this object is built fresh with its store; closing the board is the reset.
/// `lastActiveLaneID` is per-open in the same sense "in this window session" is exactly the
/// scope of a container built with its store but it *does* reference an item, so `resolve` has
/// something to say about it.
///
/// `@MainActor` because it is read by SwiftUI on the main actor and mutated by gestures there;
/// `@Observable` so the board window and its card windows re-render off the same truth.
@@ -238,7 +307,7 @@ public final class TransientBoardState {
/// of its own.
public var searchQuery: String = ""
// MARK: The overlay
// MARK: The inline editors
/// The new-card placeholder, or `nil` when no card is being created. See `NewCardPlaceholder`
/// for what it is and `resolve(against:)` for what a reload does to it.
@@ -247,8 +316,40 @@ public final class TransientBoardState {
/// card is exactly the kind of state that rots if anyone may assign it.
public private(set) var newCardPlaceholder: NewCardPlaceholder?
/// The inline rename in flight, or `nil` when no title is being edited. See `RenameEditor`.
///
/// `private(set)` for the placeholder's reason, plus one of its own: the *commit* is a write
/// that only `BoardStore` can perform, so an editor assignable from anywhere could be cleared
/// out from under a commit that was about to read its draft.
public private(set) var renameEditor: RenameEditor?
/// Whether a title editor holds focus 04-interactions.md's **focused-editor rule** as one
/// boolean: "while an inline title editor rename or the new-card placeholder is focused,
/// board-scoped menu commands (Delete, New Card, Paste, Move, Style, ) disable via menu
/// validation".
///
/// Every board-mutating menu item validates against this, so the rule is stated once rather
/// than re-derived per item. The one carve-out the design names Open Card , which stays
/// enabled to commit the edit and open the window is the item's business, not this flag's.
public var isEditingInline: Bool {
newCardPlaceholder != nil || renameEditor != nil
}
// MARK: Per-open values
/// The lane that most recently held selection or a creation **in this window session**
/// 04-interactions.md's N target rule's fallback when nothing (or a tombstoned something) is
/// selected, before the last resort of the first lane.
///
/// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing
/// in the snapshot that could reconstruct which lane the user was last working in, which is
/// precisely why the rule exists a N after an Escape should file the card where the user
/// has been, not at the far left of the board.
///
/// `resolve(against:)` clears it when the lane vanishes, because a target that renders nowhere
/// is no target at all; `NewCardTarget` then falls through to the first lane.
public private(set) var lastActiveLaneID: ItemID?
/// Whether the trash quasi-lane is showing (03-board-ui.md Trash).
///
/// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout
@@ -276,15 +377,37 @@ public final class TransientBoardState {
selection = .empty
}
/// Records that `laneID` is where the user is working a lane selected, or created into.
///
/// **`nil` is a no-op, not a clear.** "Last-active" is a high-water mark: clearing the
/// selection does not un-happen the lane the user was just in, and 04-interactions.md's rule
/// leans on exactly that (N *with nothing selected* is the case the memory serves). The only
/// thing that clears it is the lane going away, which `resolve(against:)` owns.
public func noteActiveLane(_ laneID: ItemID?) {
guard let laneID else { return }
lastActiveLaneID = laneID
}
// MARK: - The placeholder's lifecycle
/// Opens the inline editor for a new card in `laneID`, replacing any placeholder already open.
/// Opens the inline editor for a new card in `laneID`, replacing any editor already open and
/// marking the lane active.
///
/// Replacing rather than refusing: two placeholders can never be open at once (one inline editor,
/// one focus), so a second begin is the first one being abandoned 04-interactions.md's
/// click-away discard, arriving as a new creation instead of a click.
public func beginPlaceholder(inLane laneID: ItemID) {
newCardPlaceholder = NewCardPlaceholder(laneID: laneID)
/// Replacing rather than refusing: two inline editors can never be open at once (one focus),
/// so a second begin is the first one being abandoned 04-interactions.md's click-away
/// discard, arriving as a new creation instead of a click (02-architecture.md § Layering
/// states it outright: "Starting a new creation while a placeholder is open is a click-away
/// for the draft"). A rename in flight is dropped for the same reason; a rename's click-away
/// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the
/// focus is being taken by another editor before the user has said they are done.
///
/// - Parameter anchorCardID: the card the new one is born immediately after (04's N target
/// rule), or `nil` for the lane's bottom which is what Return, the header button, and a
/// double-click on empty space all pass.
public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) {
renameEditor = nil
newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID)
noteActiveLane(laneID)
}
/// Records what the user has typed. A no-op with no placeholder open the draft has nowhere to
@@ -312,6 +435,37 @@ public final class TransientBoardState {
newCardPlaceholder = nil
}
// MARK: - The rename editor's lifecycle
/// Opens the inline rename of `targetID`, seeded with `currentTitle`.
///
/// The seed is the caller's because this type holds no snapshot: the two entry points (Return
/// on a sole selected card, Board Rename) both have the item in hand already. `nil` seeds an
/// empty field an untitled item has no title to edit, and "Untitled" is a rendering that
/// must never be typed into the file (03-board-ui.md § Card face).
///
/// Replaces whatever editor was open, for `beginPlaceholder`'s reason: one focus, one editor.
public func beginRename(of targetID: ItemID, currentTitle: String?) {
newCardPlaceholder = nil
renameEditor = RenameEditor(targetID: targetID, draftTitle: currentTitle ?? "")
}
/// Records what the user has typed. A no-op with no editor open, like `updateDraft`.
public func updateRenameDraft(_ title: String) {
renameEditor?.draftTitle = title
}
/// Closes the rename editor without writing **Escape's abandon**, and also how
/// `BoardStore.commitRename` retires the editor once its write has been issued (or refused).
///
/// There is no `commitRename` here for the same reason there is no create here: this type
/// stores no URLs and performs no I/O. The keystrokes are simply dropped; on the abandon path
/// disk was never touched, and on the commit path disk has already been touched by the time
/// this runs.
public func discardRename() {
renameEditor = nil
}
// MARK: - Reload
/// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot.
@@ -341,6 +495,19 @@ public final class TransientBoardState {
/// Otherwise the placeholder survives untouched: reloads swap the snapshot *underneath* the
/// overlay, exactly as they do underneath the selection.
///
/// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md
/// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target
/// that is tombstoned, deleted, or gone discards the editor and its keystrokes silently.
/// A foreign *move* is deliberately not a vanish the editor follows the UUID and the commit
/// writes wherever the item now lives which falls out for free from matching on identity
/// rather than on position. Liveness is **effective**, so a card under a lane an agent just
/// tombstoned vanishes with it.
///
/// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md
/// gives every item-referencing piece of transient state: nothing may reference an item the
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
/// optional rather than a set on a side the rule it obeys is the same one.
///
/// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references
/// an item, so no snapshot can invalidate either the query's *results* change with every
/// snapshot, which is precisely why the results are not stored here.
@@ -349,6 +516,16 @@ public final class TransientBoardState {
dragMembers = dragMembers.resolved(against: snapshot)
pendingCut = pendingCut.resolved(against: snapshot)
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
// One universe computed once and asked three questions the rename target's liveness, the
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
let live = ItemReferenceSet.idUniverse(of: snapshot, on: .live)
if let editor = renameEditor, !live.contains(editor.targetID) {
renameEditor = nil
}
if let lane = lastActiveLaneID, !live.contains(lane) {
lastActiveLaneID = nil
}
}
/// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.
+10
View File
@@ -1193,6 +1193,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case purge(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
case resize(title: String?) // a lane's `width` the edge drag and the stepper alike (03-board-ui.md § Lane)
/// An inline title editor's commit the third inline editor's write (04-interactions.md
/// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the
/// surfaces" is settled (02-architecture.md § Write-failure surfacing, which names
/// "Couldn't rename 'Fix login'" verbatim), and a rename that failed must not tell the user
/// the app could not *restyle* something. `title` is the item's title as it stood **before**
/// the edit `updateIndex` enriches it off the document it just read which is the name the
/// user is still looking at when the banner appears.
case rename(title: String?)
case importAttachment(filename: String)
case listAttachments
case renumberChildren // order-maintenance sweep (compaction)
@@ -1218,6 +1226,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .purge: .purge(title: title)
case .style: .style(title: title)
case .resize: .resize(title: title)
case .rename: .rename(title: title)
}
}
@@ -1240,6 +1249,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .purge(title): Self.phrase("purge", title)
case let .style(title): Self.phrase("style", title)
case let .resize(title): Self.phrase("resize", title)
case let .rename(title): Self.phrase("rename", title)
case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments"
case .renumberChildren: "renumber children"
+23
View File
@@ -38,6 +38,29 @@ enum Ranks: Sendable {
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,
+105 -7
View File
@@ -20,6 +20,107 @@ extension FocusedValues {
}
}
// MARK: - Shared validation
/// The two conditions **every** board-mutating menu item disables on, in one place.
///
/// - **The read-only lock** (02-architecture.md § The lock's scope): "every mutating command
/// disables via menu validation" across every window sharing the store. An item that is going to
/// be refused should not look available.
/// - **The focused-editor rule** (04-interactions.md Grammar, settled): "while an inline title
/// editor rename or the new-card placeholder is focused, board-scoped menu commands (Delete,
/// New Card, Paste, Move, Style, ) disable via menu validation" and the keyboard belongs to the
/// text domain. The one carve-out the design names is Open Card , which stays enabled to commit
/// the edit and open the window it is not a menu item yet (m5), and when it is, it is the one
/// item that must *not* read this property.
///
/// Stated once rather than repeated per item, because the interesting failure mode is an item that
/// quietly forgets half of it.
extension BoardStore {
var acceptsBoardMutations: Bool {
!isReadOnly && !isEditingInline
}
}
// MARK: - Creation items
/// File New Card (N) and File New Lane (N) 11-command-nexus.md's two creation rows.
///
/// **New Card resolves its target through `NewCardTarget`**, the N target rule as a pure function,
/// and uses the *same* answer for its `disabled` state as for its action: a `nil` resolution is the
/// zero-lane board, where "card creation and card paste have no target New Card, Return-creation,
/// and Paste with a card payload disable via menu validation until a lane exists"
/// (04-interactions.md The map). Two derivations of that condition would be two chances to
/// disagree.
///
/// **New Lane is enabled whenever the board accepts writes.** It is the way *out* of a zero-lane
/// board "New Lane (N) is one way in" so it can have no selection precondition at all. The
/// lane it creates is untitled and no editor opens on it; see `BoardStore.createLane`.
struct BoardCreationCommands: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("New Card") {
guard let store, let target = newCardTarget else { return }
store.transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
}
.keyboardShortcut("n", modifiers: .command)
.disabled(newCardTarget == nil)
Button("New Lane") {
store?.createLane()
}
.keyboardShortcut("n", modifiers: [.shift, .command])
.disabled(store?.acceptsBoardMutations != true)
}
/// Where N would file a card, or `nil` when it cannot no focused board, a board that refuses
/// writes, an inline editor holding the keyboard, or a board with no lanes.
private var newCardTarget: NewCardTarget.Resolution? {
guard let store, store.acceptsBoardMutations else { return nil }
return NewCardTarget.resolve(
selection: store.selection,
lastActiveLaneID: store.transient.lastActiveLaneID,
snapshot: store.snapshot
)
}
}
// MARK: - Rename
/// Board Rename no default chord, deliberately (11-command-nexus.md: " (cards: Return in
/// place)"), and remappable like any other item.
///
/// It "exists for completeness and remapping" for cards, whose real path is Return, and it is a
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
/// never be renamed at all (04-interactions.md Selection).
///
/// Validation is the sole-selected-live-item rule card or lane, either kind, exactly one. A
/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned
/// selections" (04 The trash), which `ItemReferenceSet`'s liveness side answers directly.
struct BoardRenameCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Rename") {
guard let store, let target = renameTarget else { return }
store.transient.beginRename(of: target.id, currentTitle: target.title)
}
.disabled(renameTarget == nil)
}
private var renameTarget: (id: ItemID, title: String?)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
let item = BoardStore.liveItem(id, in: store.snapshot)
else { return nil }
return (id: id, title: item.title)
}
}
// MARK: - Lane width items
/// Increase / Decrease Lane Width **the width stepper's keyboard face** (03-board-ui.md § Lane,
@@ -31,9 +132,7 @@ extension FocusedValues {
/// **Validation is the sole-selected-lane rule.** Both items are enabled only when the focused
/// board's selection resolves to exactly one live lane; a card selection, a multi-selection, a
/// trash-side selection and an empty one all disable them. Decrease additionally disables at one
/// unit, which is the floor. Nothing selects a lane yet the lane-chrome card wires the header
/// click (04-interactions.md Selection) so these validate-disable in today's build, which is
/// expected rather than broken.
/// unit, which is the floor.
struct LaneWidthCommands: View {
@FocusedValue(\.boardStore) private var store
@@ -54,11 +153,10 @@ struct LaneWidthCommands: View {
/// The sole selected live lane, or `nil` the whole of these items' validation.
///
/// A read-only board disables every mutating command (02-architecture.md § The lock's scope), so
/// the lock is folded in here rather than left for the write to refuse: an item that is going to
/// fail should not look available.
/// The lock and the open-editor rule are folded in through `acceptsBoardMutations` rather than
/// left for the write to refuse: an item that is going to fail should not look available.
private var selectedLane: Lane? {
guard let store, !store.isReadOnly else { return nil }
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
return store.snapshot.lanes.first { $0.id == id && !$0.isDeleted }
+196 -11
View File
@@ -15,11 +15,22 @@ import SwiftUI
/// keyboard face re-divides the existing window width across the new unit total, compressing the
/// siblings and never touching the window (`BoardStore.setLaneWidth`).
///
/// ### The three interactions it hosts
///
/// - **Lane resize** the right-edge grab strip (above).
/// - **Lane reorder** the whole title bar is the drag surface (`LaneReorderSession`,
/// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be
/// order.
/// - **The keyboard's narrow slice** Return's create/rename dispatch and Escape's step outward.
///
/// ### What is deliberately not here yet
///
/// Selection, drag and drop, the trash quasi-lane, the toolbar, search, styling and the lane context
/// menu all belong to later milestone cards. This view is the layout and the resize interaction, and
/// the chrome inside `LaneView` is a placeholder those cards replace.
/// The trash quasi-lane, the toolbar, search, styling, the lane context menu, and drag & drop's real
/// machinery (multi-drag, cross-board locality, the shadow's hold rule) all belong to later
/// milestone cards, and the card face inside `LaneView` is still a stub those cards replace. The
/// **selection grammar** here is likewise minimal a click replaces the selection, and that is all:
/// -click toggling, -click ranges, the rubber band and the cards-XOR-lanes homogeneity rule are
/// m5's selection-model card.
struct BoardView: View {
let store: BoardStore
@@ -29,10 +40,23 @@ struct BoardView: View {
/// after the first body evaluation.
let window: @MainActor () -> NSWindow?
/// Opens a card's window 's second half (04-interactions.md Grammar). A closure from
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
/// needs the board's own window ref, which is the host's identity and not the board's.
let openCard: (ItemID) -> Void
/// One resize at a time, per window. `@State` so it lives exactly as long as this board window's
/// view does, which is the interaction's whole lifetime.
@State private var resize = LaneResizeSession()
/// One reorder at a time, per window same lifetime, same reasoning.
@State private var reorder = LaneReorderSession()
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
/// must go back to meaning create/rename rather than nothing at all.
@FocusState private var isBoardFocused: Bool
/// The inter-lane gap, and the strip's outer margin one number, because the standard-width
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
private let spacing: CGFloat = 12
@@ -51,16 +75,38 @@ struct BoardView: View {
stripWidth: viewport.size.width,
totalUnits: LaneLayoutMath.totalUnits(of: lanes),
gap: spacing)
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
// the drag's would-be order while a reorder is in flight which is how the siblings
// reflow to make room (04-interactions.md Drag and drop). The proposal is recomputed
// here on every render, so a foreign reload mid-drag simply moves the zones (rule 1 of
// that section's re-grounding trio).
let shown = shownLanes(lanes, standard: standard)
HStack(alignment: .top, spacing: spacing) {
ForEach(lanes) { lane in
laneSlot(lane, standard: standard)
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
laneSlot(lane, at: position, among: shown, standard: standard)
}
}
.padding(spacing)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
// the strip is the window's content, not a control, and a rectangle around the whole board
// would read as an error state.
.focusable()
.focusEffectDisabled()
.focused($isBoardFocused)
.onAppear { isBoardFocused = true }
.onChange(of: store.isEditingInline) { _, editing in
// An editor took focus and has now given it back. Without this the strip stays unfocused
// after every rename and Return silently stops working.
if !editing { isBoardFocused = true }
}
.onKeyPress(.return) { handleReturn() }
.onKeyPress(.escape) { handleEscape() }
}
// MARK: - Lanes
/// One lane's strip slot, plus its trailing grab strip.
///
/// Normally a plain `LaneView` sized to its unit count's slot width (a wide lane swallows the
@@ -76,9 +122,15 @@ struct BoardView: View {
///
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
/// off the tidy snapped layout regardless of the live overflow.
///
/// While this lane is being **reordered** the slot instead keeps its resting size and travels:
/// the offset is the gap between where the pointer has carried it and where it would rest under
/// the current proposal, so it tracks the cursor 1:1 while its siblings sit in the would-be
/// order beneath it (`zIndex(2)`, above even a resize).
@ViewBuilder
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
private func laneSlot(_ lane: Lane, at position: Int, among shown: [Lane], standard: CGFloat) -> some View {
let resizing = resize.isResizing(lane.id)
let dragging = reorder.isDragging(lane.id)
let units = resizing ? resize.units : LaneLayoutMath.displayUnits(of: lane)
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
ZStack(alignment: .topLeading) {
@@ -93,11 +145,20 @@ struct BoardView: View {
// continuous width and not the not-yet-committed `lane.width`. The live width still
// narrows and widens the columns continuously, so the cards reflow under the cursor
// between ticks (free via `MasonryLayout`).
LaneView(lane: lane, columns: units)
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
LaneView(
store: store,
lane: lane,
columns: units,
reorder: reorder,
headerDrag: headerDrag(at: position, among: shown, standard: standard),
openCard: openCard
)
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
}
.frame(width: slotWidth, alignment: .topLeading)
.zIndex(resizing ? 1 : 0)
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
.opacity(dragging ? 0.9 : 1)
.zIndex(dragging ? 2 : (resizing ? 1 : 0))
.overlay(alignment: .trailing) {
LaneResizeHandle(
store: store,
@@ -112,8 +173,10 @@ struct BoardView: View {
// (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag
// resizes the *window* on the way, so a refused commit would leave the window grown
// around a lane that snapped back and the lock's row is already saying why nothing
// can be written.
.disabled(store.isReadOnly)
// can be written. The focused-editor rule closes it too, like every board command, and
// so does a reorder in flight: two drags mutating one strip layout is not a state this
// view has a meaning for.
.disabled(store.isReadOnly || store.isEditingInline || reorder.isActive)
}
}
@@ -123,6 +186,128 @@ struct BoardView: View {
private var liveLanes: [Lane] {
store.snapshot.lanes.filter { !$0.isDeleted }
}
// MARK: - Reorder
/// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in
/// flight. A drag whose lane has vanished from the snapshot shows the plain order and proposes
/// nothing its release then cancels (04 Drag and drop, "an emptied drag cancels itself").
private func shownLanes(_ lanes: [Lane], standard: CGFloat) -> [Lane] {
guard let (from, to) = proposal(among: lanes, standard: standard) else { return lanes }
return LaneReorderMath.reordered(lanes, from: from, to: to)
}
/// Where the dragged lane sits in `lanes` and where it would land `nil` when no reorder is in
/// flight, or when the lane it is carrying is no longer on the board.
private func proposal(among lanes: [Lane], standard: CGFloat) -> (from: Int, to: Int)? {
guard let id = reorder.laneID, let from = lanes.firstIndex(where: { $0.id == id }) else { return nil }
let to = LaneReorderMath.proposedIndex(
unitCounts: unitCounts(of: lanes),
draggedIndex: from,
dragCentreX: reorder.centre,
standard: standard,
gap: spacing
)
return (from, to)
}
/// How far the travelling lane is drawn from the slot it would rest in the pointer's position
/// minus the proposal's. Zero at the instant a tick lands, growing again as the pointer moves
/// on, which is what makes the replica read as *held* rather than as snapping.
private func travelOffset(at position: Int, among shown: [Lane], standard: CGFloat) -> CGFloat {
reorder.centre - LaneReorderMath.centre(
ofLaneAt: position,
unitCounts: unitCounts(of: shown),
standard: standard,
gap: spacing
)
}
/// The strip's half of a lane header's drag: where the lane rests now, and what a release means.
private func headerDrag(at position: Int, among shown: [Lane], standard: CGFloat) -> LaneHeaderDrag {
LaneHeaderDrag(
startCentre: {
LaneReorderMath.centre(
ofLaneAt: position,
unitCounts: unitCounts(of: shown),
standard: standard,
gap: spacing
)
},
commit: { commitReorder(standard: standard) }
)
}
/// Releases the drag: re-derive the proposal against the snapshot **as it is now** and write it.
///
/// Re-deriving rather than trusting the last rendered proposal is 04-interactions.md Drag and
/// drop's re-grounding rule at its most consequential moment: a reload that landed between the
/// last render and the release must not be written over. A lane that vanished in that window
/// yields no proposal and the release simply cancels "release with no valid proposal cancels;
/// items return, nothing is written".
///
/// `BoardStore.moveLane` owns the rest, the unchanged-index no-op included.
private func commitReorder(standard: CGFloat) {
defer { reorder.end() }
guard let id = reorder.laneID,
let (_, to) = proposal(among: liveLanes, standard: standard)
else { return }
store.moveLane(id, toIndex: to)
}
private func unitCounts(of lanes: [Lane]) -> [Int] {
lanes.map { LaneLayoutMath.displayUnits(of: $0) }
}
// MARK: - Grammar keys
/// **Return**, narrowly (04-interactions.md Grammar): a sole selected live card begins an
/// inline rename, a sole selected live lane begins a new-card placeholder at its bottom, and
/// everything else is ignored a multi-card selection is explicitly inert, and a lane's rename
/// path is Board Rename precisely because Return on a lane creates.
///
/// The full keyboard map arrows, -jumps, the escalation, , the moves is **m5's
/// keyboard-grammar card**. This is the creation/rename pair and nothing else.
///
/// Inert while an inline editor is open: "all grammar keys inert while a title editor is
/// focused". The field consumes Return itself, so this guard is belt over braces but the belt
/// matters, because a stray Return reaching here mid-edit would open a *second* editor.
private func handleReturn() -> KeyPress.Result {
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard selection.liveness == .live,
selection.ids.count == 1,
let id = selection.ids.first,
let target = BoardStore.liveItem(id, in: store.snapshot)
else { return .ignored }
if target.cardID == nil {
store.transient.beginPlaceholder(inLane: target.laneID)
} else {
store.transient.beginRename(of: id, currentTitle: target.title)
}
return .handled
}
/// **Escape steps outward one layer per press** (04 Grammar): abandon an open editor, else
/// clear the selection.
///
/// The middle step clearing an active search and returning focus to the board is m5's, and
/// it slots between these two once the search field exists.
///
/// The editors handle Escape themselves while they hold focus; this branch is the outer net for
/// the case where focus has drifted off the field with an editor still open, and it abandons
/// both kinds because at most one can be open at a time.
private func handleEscape() -> KeyPress.Result {
if store.isEditingInline {
store.transient.discardPlaceholder()
store.transient.discardRename()
return .handled
}
guard !store.selection.isEmpty else { return .ignored }
store.clearSelection()
return .handled
}
}
// MARK: - Resize shadow
+101
View File
@@ -0,0 +1,101 @@
import CoreGraphics
/// The lane-reorder drag's geometry, as pure arithmetic no view, no session, no snapshot
/// (`LaneReorderMathTests`). `LaneLayoutMath`'s sibling: that one owns the resting layout and the
/// right-edge resize, this one owns "where would the lane land if I let go now".
///
/// **Geometry-based, so the proposal is stable rather than jittery** (04-interactions.md Drag and
/// drop): the answer is a function of analytically computed resting positions and one pointer
/// coordinate never of measured mid-flight frames, which are garbage precisely during the reflow
/// they trigger (03-board-ui.md § Motion, "Motion never feeds back into logic").
///
/// **Width-aware by construction.** The design asks for "no reflow until the cursor reaches where
/// the dragged lane would actually land"; comparing against each remaining lane's *centre* is
/// exactly that a 3× lane's centre is three units along, so the drag has to travel most of that
/// lane's width before the board proposes stepping past it, and a 1× lane yields quickly.
///
/// ### What this deliberately is not
///
/// The full drag model the shadow's hold-until-a-new-candidate rule, multi-drag's N contiguous
/// shadows, cross-board locality with its copy/move badge, and the mid-drag re-grounding rules is
/// **m5's drag card**, which replaces this file's callers with the real `DropSlot`
/// (02-architecture.md § Layering Components). What is here is the within-board single-lane case
/// and nothing else, deliberately small enough to be obviously correct.
enum LaneReorderMath {
/// Where the dragged lane would land: an index into the ordered live lanes **with the dragged
/// lane removed**, so the result is in `0...(unitCounts.count - 1)` and `draggedIndex` itself
/// means "back where it started".
///
/// - Parameters:
/// - unitCounts: the ordered live lanes' display units (`LaneLayoutMath.displayUnits`), the
/// board as it currently is recomputed against each snapshot rather than frozen at drag
/// start, so a foreign lane add or tombstone mid-drag just moves the zones and the next
/// proposal targets the board as it now is (04 Drag and drop, rule 1).
/// - draggedIndex: the dragged lane's position in `unitCounts`.
/// - dragCentreX: the dragged lane's centre under the cursor, in strip coordinates (0 at the
/// strip's leading edge, outer margin included).
/// - standard: the 1× lane width (`LaneLayoutMath.standardWidth`).
/// - gap: the inter-lane gap, which is also the strip's outer margin.
///
/// Out-of-range `draggedIndex` yields `0` rather than trapping: the lane vanished under the
/// drag, and the caller's release-with-no-valid-proposal rule cancels anyway.
static func proposedIndex(
unitCounts: [Int],
draggedIndex: Int,
dragCentreX: CGFloat,
standard: CGFloat,
gap: CGFloat
) -> Int {
guard unitCounts.indices.contains(draggedIndex) else { return 0 }
var remaining = unitCounts
remaining.remove(at: draggedIndex)
// The remaining lanes' resting centres, left to right, in the layout they would have with
// the dragged lane gone which is the layout the siblings are already showing.
// Monotonically increasing, so "how many centres has the cursor passed" is both the answer
// and the reason it never oscillates: one threshold per slot, crossed once.
var index = 0
var x = gap
for units in remaining {
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
guard dragCentreX > x + width / 2 else { break }
index += 1
x += width + gap
}
return index
}
/// The resting centre of the lane at `index` in a strip of `unitCounts`, in the same strip
/// coordinates `proposedIndex` reads.
///
/// Two callers, and they are the two halves of the drag: the gesture freezes this at drag start
/// as the origin its translation is measured from (the *physical pointer* being the only live
/// input 03 § Motion), and the view offsets the travelling lane from the centre it would rest
/// at under the current proposal, which is what makes the replica track the cursor while the
/// siblings sit in their would-be order.
static func centre(ofLaneAt index: Int, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
var x = gap
for (position, units) in unitCounts.enumerated() {
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
if position == index { return x + width / 2 }
x += width + gap
}
return x
}
/// `unitCounts` (or any per-lane values) with the item at `from` moved to `to`, where `to` is
/// counted **with the item already removed** the ordering `proposedIndex` returns, applied.
///
/// Shared by the view (which reorders the lanes it lays out, so the siblings show the would-be
/// order) and by the drag's own centre arithmetic, so the two can never disagree about what the
/// proposal means.
static func reordered<T>(_ items: [T], from: Int, to: Int) -> [T] {
guard items.indices.contains(from) else { return items }
var result = items
let item = result.remove(at: from)
result.insert(item, at: min(max(0, to), result.count))
return result
}
}
+69
View File
@@ -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
}
}
+500 -44
View File
@@ -1,15 +1,47 @@
import SwiftUI
/// One lane: a header and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
// MARK: - The strip's half of the header drag
/// What the strip lends a lane so its **whole title bar** can be the drag surface (03-board-ui.md §
/// Lane, "no separate grip").
///
/// **The chrome here is deliberately minimal**, and the design's real lane is a later card: the
/// leading SF Symbol, the new-card button, the drag surface, the context menu (Rename, Style, the
/// quick-style recents row, the Width stepper, Delete), the colour accent band, inline rename and
/// the search-aware count all arrive with the lane-chrome milestone. What this view owes the
/// full-visibility layout card is the shape a header, a body that scrolls vertically only, and a
/// masonry whose interior column count is the lane's width and that is all it does.
/// The gesture lives on the header that is where the design puts it but the two things it needs
/// are the strip's: where this lane currently rests (the origin the translation is measured from)
/// and what a release means (a proposal computed against the live lane order, then a write). Both
/// arrive as closures rather than as values because both must be read at *gesture* time, not at
/// body-evaluation time.
@MainActor
struct LaneHeaderDrag {
/// This lane's resting centre in strip coordinates, read the instant the drag begins and frozen
/// for its duration (`LaneReorderSession.startCentre`).
let startCentre: () -> CGFloat
/// Commit the reorder at whatever the current proposal is, and end the session.
let commit: () -> Void
}
// MARK: - LaneView
/// One lane: a title bar and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
///
/// ### The title bar (this milestone's subject)
///
/// Leading SF Symbol from `icon` lenient, an unknown name renders the `square.stack` default
/// (`ItemSymbol`) then the title or its quiet "Untitled" placeholder, a quiet secondary
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
/// a plain click selects the lane, movement past a small threshold begins a reorder
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
///
/// ### What is still a later card's
///
/// The lane context menu (Rename, Style, the quick-style recents row, the Width stepper, Delete),
/// the colour accent band, and the search-aware filtering behind the count all belong to later
/// milestones. The **card face** is likewise still a stub `CardStubView` gains the leading icon,
/// the attachment chip, the cut treatment and the attachment carousel with the card-face card; what
/// it grows here is only what inline rename and click selection require.
struct LaneView: View {
let store: BoardStore
let lane: Lane
/// Interior masonry columns the lane's width units, or the resize session's snapped count
@@ -17,77 +49,501 @@ struct LaneView: View {
/// override it (see `BoardView.laneSlot`).
let columns: Int
/// The strip's reorder session, so the header knows whether *it* is the lane in flight.
let reorder: LaneReorderSession
let headerDrag: LaneHeaderDrag
/// Opens a card's window 's second half (04-interactions.md Grammar, "commits and opens
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
/// business knowing about `WindowGroup` keys.
let openCard: (ItemID) -> Void
/// Spacing between cards, and between the interior columns.
private let cardSpacing: CGFloat = 8
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
ScrollView(.vertical) {
// Cards stay standard width whatever the lane spans: at a slot width of
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
// `units` columns of `standard` (03-board-ui.md § Layout full visibility).
MasonryLayout(columns: columns, spacing: cardSpacing) {
ForEach(liveCards) { card in
CardStubView(card: card)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
cardStack
}
.padding(6)
.background(selectionBackground)
.overlay(selectionStroke)
}
/// The header title, or a quiet "Untitled" where there is none, plus the live card count.
///
/// Titles are optional at every level (03-board-ui.md § Card face): a missing `title` renders as
/// a secondary-styled placeholder rather than as an empty row. **Replaced wholesale by the
/// lane-chrome card**, which brings the icon, the count badge's real styling, the new-card
/// button, the drag surface and the context menu.
// MARK: - Header
private var header: some View {
headerContent
// The bar is the drag surface, so it must be hit-testable across its whole width
// including the empty stretch between the badge and the button.
.contentShape(Rectangle())
.gesture(headerGesture)
.overlay(alignment: .trailing) { newCardButton }
}
private var headerContent: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
.foregroundStyle(.secondary)
.imageScale(.medium)
headerTitle
countBadge
Spacer(minLength: 0)
}
// Reserves the button's width so a long title truncates before it collides, and keeps the
// button out of the gestured region.
.padding(.trailing, 22)
.padding(.horizontal, 4)
}
/// The title, or the rename editor when this lane is the one being renamed.
///
/// A lane's **only** rename path is Board Rename (04-interactions.md Selection: "the menu
/// item is a lane's only rename path, since Return on a lane creates a card"), so nothing in
/// this view opens the editor it only renders one that is already open.
@ViewBuilder
private var headerTitle: some View {
if isRenaming {
InlineTitleField(
text: renameDraft,
prompt: "Lane name",
onCommit: { store.commitRename() },
onAbandon: { store.transient.discardRename() },
onFocusLoss: { store.commitRename() },
// A lane has no card window; still commits, which is the half of the rule that
// applies (04 Grammar's carve-out is "commits the edit placeholder or rename
// and open[s] the card window", and only a card has one to open).
onCommitAndOpen: { store.commitRename() }
)
.font(.headline)
} else {
Text(lane.title.value ?? "Untitled")
.font(.headline)
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.tail)
Text("\(liveCards.count)")
.font(.callout)
.foregroundStyle(.secondary)
.monospacedDigit()
Spacer(minLength: 0)
}
.padding(.horizontal, 4)
}
/// The card-count badge quiet, secondary (03-board-ui.md § Lane).
///
/// **It counts exactly what the body renders**, because it reads the same `renderedCards` the
/// masonry iterates. That is deliberate rather than incidental: "The count reads the search
/// filter like every other surface during a search it shows the visible count, not the
/// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge
/// follows by construction, with no second rule to keep in step.
private var countBadge: some View {
Text("\(renderedCards.count)")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Capsule().fill(.quaternary))
}
/// The new-card button a **pointer twin** of File New Card whose click *names its target*:
/// "the lane header's new-card button overrides [the N target] rule the click names its
/// target lane, selection notwithstanding" (11-command-nexus.md Pointer grammar, settled), so
/// it passes this lane and no anchor rather than consulting `NewCardTarget`.
private var newCardButton: some View {
Button {
store.transient.beginPlaceholder(inLane: lane.id)
} label: {
Image(systemName: "plus")
.imageScale(.small)
.foregroundStyle(.secondary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("New card in \(lane.title.value ?? "Untitled")")
// Mutating, so the read-only lock disables it like every other write path
// (02-architecture.md § The lock's scope), and the focused-editor rule closes it while an
// inline editor is open (04 Grammar) the pointer twin of a disabled menu item.
.disabled(store.isReadOnly || store.isEditingInline)
}
/// One gesture recognising both halves of 04-interactions.md Selection's click-vs-drag split:
/// "a plain click on the title bar selects the lane; the drag surface engages only on movement".
///
/// `minimumDistance: 0` so the release is seen even when nothing moved that release *is* the
/// click. `.global` coordinates because the strip's own space shifts as siblings reflow under
/// the proposal, and a translation measured against a moving frame is not a pointer delta.
private var headerGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
// Selection stays live under the lock; reordering does not (02 § The lock's scope).
// The focused-editor rule holds a drag off too: a reorder is a board command.
guard !store.isReadOnly, !store.isEditingInline else { return }
if !reorder.isDragging(lane.id) {
guard abs(value.translation.width) > LaneReorderSession.threshold else { return }
reorder.begin(laneID: lane.id, startCentre: headerDrag.startCentre())
}
reorder.update(translation: value.translation.width)
}
.onEnded { _ in
if reorder.isDragging(lane.id) {
headerDrag.commit()
} else {
// A plain click on the header always selects unlike lane empty space, it does
// not toggle off. 04 gives the click-again-to-unselect behaviour to empty space
// only, and a full lane has no empty space to reach for.
store.select([lane.id], liveness: .live)
}
}
}
// MARK: - Body
/// The card stack. Its empty space is a click target in its own right (04 Selection): one
/// click selects the lane or, when it is already the selection, clears it; a double click
/// creates a card at the bottom with its title editor focused.
private var cardStack: some View {
ScrollView(.vertical) {
// Cards stay standard width whatever the lane spans: at a slot width of
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
// `units` columns of `standard` (03-board-ui.md § Layout full visibility).
MasonryLayout(columns: columns, spacing: cardSpacing) {
ForEach(slots) { slot in
switch slot {
case let .card(card):
CardStubView(store: store, card: card, openCard: openCard)
case .placeholder:
NewCardStubView(store: store, openCard: openCard)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.contentShape(Rectangle())
// Order matters: the two-tap recogniser must be attached first so a double click is not
// consumed as two singles.
.onTapGesture(count: 2) {
guard !store.isReadOnly, !store.isEditingInline else { return }
store.transient.beginPlaceholder(inLane: lane.id)
}
.onTapGesture { toggleLaneSelection() }
}
}
/// What the masonry lays out: the rendered cards, plus the new-card placeholder when this lane
/// is the one being created into.
///
/// The overlay is inserted **at the position the card will actually take** after its anchor
/// for N's "immediately after it", at the bottom otherwise by asking the very function the
/// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card
/// cannot appear anywhere but where the real card lands.
private var slots: [LaneSlot] {
var result = renderedCards.map(LaneSlot.card)
guard let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id else {
return result
}
let position = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
result.insert(.placeholder, at: position ?? result.count)
return result
}
/// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane the
/// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
/// tombstoned lane at all.
private var liveCards: [Card] {
///
/// This is also the collection m5's search filter narrows, which is what keeps the count badge
/// honest for free see `countBadge`.
private var renderedCards: [Card] {
lane.cards.filter { !$0.isDeleted }
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
}
/// Click on empty space: select, or clear when this lane is already *the* selection.
///
/// "Single click selects the lane (click again to unselect)". The toggle-off tests for a
/// sole-membership selection rather than mere containment, so a future -click multi-selection
/// of lanes is narrowed by a click rather than wiped by it the modifier grammar itself
/// (-click toggles, -click range-extends, rubber band, homogeneity enforcement) is **m5's
/// selection-model card**, and nothing here should pre-empt it.
private func toggleLaneSelection() {
if store.selection.liveness == .live, store.selection.ids == [lane.id] {
store.clearSelection()
} else {
store.select([lane.id], liveness: .live)
}
}
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet
/// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as
/// a fill that would compete with it once that lands.
private var selectionBackground: some View {
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear))
}
private var selectionStroke: some View {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
}
// MARK: - Rename plumbing
private var isRenaming: Bool {
store.transient.renameEditor?.targetID == lane.id
}
/// The draft, as a binding onto transient state rather than as `@State`: the editor's text lives
/// in `TransientBoardState` because a reload has rules about it (the vanish discard), and a
/// second copy in the view would be the one the commit did not read.
private var renameDraft: Binding<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
// MARK: - Lane slots
/// What a lane's masonry lays out its cards, plus at most one pseudo-card.
///
/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its
/// title commits (02-architecture.md § Layering, the one named exception to the one-way flow).
/// Modelling it as a sibling case rather than as a fake `Card` is what keeps that true nothing can
/// accidentally hand it to code expecting an item that exists.
private enum LaneSlot: Identifiable {
case card(Card)
case placeholder
var id: String {
switch self {
case let .card(card): "card:\(card.id.rawValue)"
// Constant, because there is only ever one placeholder in one lane at a time and it must
// keep its identity and therefore its keyboard focus while the user types.
case .placeholder: "placeholder"
}
}
}
// MARK: - Card stub
/// A card, as a rounded plate with its title **a stand-in, replaced by the card-face card**, which
/// brings the leading icon, the attachment chip, the selection and cut treatments, inline rename and
/// the sole-selected card's attachment carousel (03-board-ui.md § Card face).
/// A card, as a rounded plate with its title **still a stand-in**, replaced by the card-face card,
/// which brings the leading icon, the attachment chip, the cut treatment and the sole-selected
/// card's attachment carousel (03-board-ui.md § Card face).
///
/// It takes whatever width `MasonryLayout` proposes (one interior column = one standard width) and
/// sizes its own height to its content, which is what makes the masonry masonry: a taller card only
/// pushes the cards below it in its own column.
/// What it has grown here is only what this milestone owes: click-to-select with a selection
/// treatment, and the inline rename editor swapping in for the title when this card is the rename
/// target.
private struct CardStubView: View {
let store: BoardStore
let card: Card
let openCard: (ItemID) -> Void
var body: some View {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
Group {
if isRenaming {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { store.commitRename() },
onAbandon: { store.transient.discardRename() },
// **Click-away commits** a rename's rule, and the deliberate opposite of the
// placeholder's (04-interactions.md Grammar: "focus loss = commit, matching
// the card window's title field").
onFocusLoss: { store.commitRename() },
onCommitAndOpen: {
let id = card.id
store.commitRename()
openCard(id)
}
)
.font(.body)
} else {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename.
.onTapGesture { store.select([card.id], liveness: .live) }
}
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(card.id)
}
private var isRenaming: Bool {
store.transient.renameEditor?.targetID == card.id
}
private var draft: Binding<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
// MARK: - The new-card placeholder
/// The card being created, drawn as a pseudo-card in the masonry flow at standard card width
/// 02-architecture.md § Layering's one named exception to the one-way flow, finally rendered.
///
/// Two faces, one per phase:
///
/// - **`.editing`** a focused text field. Return commits, Escape abandons, and **click-away
/// discards**: the placeholder's rule, "the deliberate exception because nothing exists on disk
/// yet" (04-interactions.md Grammar).
/// - **`.awaitingArrival`** the committed title as plain text, deliberately *not* an editor. The
/// Writer's create has run and the overlay is only covering the gap until the watcher round-trips
/// the real card; leaving a live field there would invite edits that have nowhere to go, and its
/// focus loss would fire the discard rule against a card that is already on its way.
private struct NewCardStubView: View {
let store: BoardStore
let openCard: (ItemID) -> Void
var body: some View {
Group {
if isEditing {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { commit() },
onAbandon: { store.transient.discardPlaceholder() },
onFocusLoss: { store.transient.discardPlaceholder() },
onCommitAndOpen: {
// The one board command that stays enabled mid-edit: commit, then open
// (04 Grammar's carve-out). A commit that discarded empty title, a
// vanished lane, a failed create hands back no id and opens nothing.
if let id = commit() { openCard(id) }
}
)
.font(.body)
} else {
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
)
}
private var isEditing: Bool {
store.transient.newCardPlaceholder?.phase == .editing
}
/// Commits, then **re-selects the lane** "Return commits and re-selects the lane (next Return
/// = next card)" (04-interactions.md Grammar). The lane rather than the new card is what makes
/// a run of Return-type-Return file a stack of cards without the user's hands leaving the
/// keyboard.
///
/// The lane is read before the commit, because every discard path clears the overlay that holds
/// it and re-checked after, because one of those paths is *the lane vanished*, and selecting
/// something that renders nowhere would break the homogeneous-by-liveness invariant until the
/// next reload swept it away.
@discardableResult
private func commit() -> ItemID? {
let lane = store.transient.newCardPlaceholder?.laneID
let id = store.commitPlaceholder()
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
store.select([lane], liveness: .live)
}
return id
}
private var draft: Binding<String> {
Binding(
get: { store.transient.newCardPlaceholder?.draftTitle ?? "" },
set: { store.transient.updateDraft($0) }
)
}
}
// MARK: - The inline title field
/// The one text field all three inline editors wear the new-card placeholder, a card rename, and
/// a lane rename so the grammar around it is written once (04-interactions.md Grammar).
///
/// The four exits, and who differs on them:
///
/// | Exit | Placeholder | Rename |
/// |---|---|---|
/// | Return | commits | commits |
/// | Escape | discards | abandons |
/// | Click-away | **discards** | **commits** |
/// | | commits + opens | commits + opens |
///
/// Only the click-away row differs, which is why it is a caller-supplied closure rather than a
/// branch in here: this view knows *that* focus left, never what that should mean.
///
/// **Every handler must be idempotent**, because the exits overlap by construction: Return commits
/// and then the field disappears, which also fires the focus-loss handler an instant later. The
/// store's `commitRename`/`commitPlaceholder` and the transient state's `discard` all no-op against
/// an editor that is already closed, so the overlap costs nothing.
private struct InlineTitleField: View {
@Binding var text: String
let prompt: String
let onCommit: () -> Void
let onAbandon: () -> Void
let onFocusLoss: () -> Void
let onCommitAndOpen: () -> Void
@FocusState private var isFocused: Bool
var body: some View {
TextField(prompt, text: $text)
.textFieldStyle(.plain)
.lineLimit(1)
.focused($isFocused)
// The editor is born focused: every entry point to it is a deliberate "edit this now"
// (Return, N, the header button, a double click, Board Rename), and one that landed
// unfocused would need a second click to do anything.
.onAppear { isFocused = true }
.onSubmit(onCommit)
// before the field sees the Return: the one board command enabled mid-edit
// (04 Grammar). Anything without the modifier is passed straight through, so plain
// Return still reaches `onSubmit`.
.onKeyPress(keys: [.return], phases: .down) { press in
guard press.modifiers.contains(.command) else { return .ignored }
onCommitAndOpen()
return .handled
}
// Escape reaches a focused text field as AppKit's cancel operation on some paths and as
// a plain key press on others; both are wired to the same idempotent abandon rather than
// guessing which one this control will get.
.onKeyPress(.escape) {
onAbandon()
return .handled
}
.onExitCommand(perform: onAbandon)
.onChange(of: isFocused) { _, focused in
guard !focused else { return }
onFocusLoss()
}
}
}
+82
View File
@@ -0,0 +1,82 @@
/// 04-interactions.md's **N target rule** (settled), as a pure function of the three things it
/// reads the selection, the last-active lane, and the snapshot (`NewCardTargetTests`).
///
/// The rule verbatim, and each clause's branch below:
///
/// > with a card selected, the new card is created in that card's lane, immediately after it
/// > (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency);
/// > with nothing selected or a **tombstoned** selection, which never anchors creation the
/// > **last-active lane** the lane that most recently held selection or a creation in this window
/// > session falling back to the first lane. **Zero-lane board**: card creation disable[s] via
/// > menu validation until a lane exists.
///
/// **A pure function rather than a method on the store** for the reason every rule in this codebase
/// that can be one is: the five branches are five lines of test rather than five UI states to drive,
/// and the menu item's `disabled` and its action then read the *same* answer instead of two
/// hand-kept-in-sync conditions.
///
/// ### What it deliberately does not decide
///
/// - **The lane header's new-card button overrides this rule entirely** (11-command-nexus.md
/// Pointer grammar, settled): "the click names its target lane, selection notwithstanding". That
/// call site passes its own lane and never comes here.
/// - **Return on a selected lane** is the same target as this rule's lane branch, but it is reached
/// by grammar rather than by the menu; it also passes its lane directly.
/// - **Multi-selections.** The rule speaks of "a card"/"a lane", singular, and a multi-selection has
/// no "it" to be immediately after. Anything but a sole selection falls through to the
/// last-active lane, which is the same answer an empty selection gets the honest reading, and
/// the one m5's selection-model card can refine if the design ever grows a plural case.
enum NewCardTarget {
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
/// lane's bottom). Exactly `NewCardPlaceholder`'s two anchoring fields, because that is what
/// this resolves *into*.
struct Resolution: Equatable {
let laneID: ItemID
let anchorCardID: ItemID?
}
/// The target, or `nil` when there is none **the zero-lane board**, where "New Card,
/// Return-creation, and Paste with a card payload disable via menu validation until a lane
/// exists". `nil` is therefore the menu item's `disabled` condition as well as its refusal, so
/// the two can never disagree.
///
/// - Parameters:
/// - selection: the board's current selection, liveness side included. A `.trashed` selection
/// "never anchors creation" and is treated exactly as an empty one the settled precedent
/// 04 Clipboard cites for paste, applied here to its source rule ("a trashed card's live
/// disk-lane never leaks in as 'the selected card's lane'").
/// - lastActiveLaneID: `TransientBoardState.lastActiveLaneID`, already cleared by the reload
/// rule if its lane vanished but re-checked here anyway, because a caller need not have
/// reloaded since the lane went.
static func resolve(
selection: ItemReferenceSet,
lastActiveLaneID: ItemID?,
snapshot: BoardModel
) -> Resolution? {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard !lanes.isEmpty else { return nil }
if selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first {
for lane in lanes {
// A lane selected: appended at its bottom, Return consistency.
if lane.id == id { return Resolution(laneID: lane.id, anchorCardID: nil) }
// A card selected: its lane, immediately after it paste-anchor consistency.
if lane.cards.contains(where: { $0.id == id && !$0.isDeleted }) {
return Resolution(laneID: lane.id, anchorCardID: id)
}
}
// The id names nothing the board renders a selection the next reload will drop.
// Falls through to the last-active lane rather than refusing: the user pressed N and
// the board has lanes.
}
// Nothing selected, a tombstoned selection, a multi-selection, or a stale one: the lane that
// most recently held selection or a creation, and the first lane when there is no such lane
// (or it has since gone).
if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) {
return Resolution(laneID: lane.id, anchorCardID: nil)
}
return lanes.first.map { Resolution(laneID: $0.id, anchorCardID: nil) }
}
}
+44
View File
@@ -0,0 +1,44 @@
import AppKit
/// The `icon` field's rendering rule: **a name that resolves is drawn, anything else falls back to
/// the level's default** (03-board-ui.md § Styling Capabilities, "`icon`: SF Symbol per item with
/// per-level defaults").
///
/// ### Lenient, never an error
///
/// `icon` is a lenient field (01-storage-format.md § Frontmatter): a typo in a hand-written name is
/// not a load failure, not a warning, and not an empty box it renders as the default and the
/// author's bytes are left exactly as written until they change them. That mirrors the read side's
/// treatment of `width`, and it is what makes "any other SF Symbol name works written by hand"
/// (03 § Controls) safe to promise: the app's curated grid is a convenience, the file is the escape
/// hatch, and a name the running OS does not happen to have simply degrades.
///
/// `NSImage(systemSymbolName:)` is the only honest test the symbol set is the *OS's*, and it grows
/// between releases, so a hard-coded allow-list would be wrong the day after it was written.
enum ItemSymbol {
/// The per-level defaults, verbatim from 03-board-ui.md § Styling Capabilities.
static let board = "rectangle.split.3x1"
static let lane = "square.stack"
static let card = "doc.text"
/// `field`'s symbol name if it names a symbol this system can draw, `fallback` otherwise.
///
/// Handles all three `FieldValue` shapes the same way, which is the point: a missing key, a
/// malformed one (a sequence where a scalar belongs), and a valid-but-unknown name are one
/// case to the renderer *there is no symbol to draw, so draw the default*.
static func name(_ field: FieldValue<String>, fallback: String) -> String {
guard let name = field.value, exists(name) else { return fallback }
return name
}
/// Whether the running system can draw `name` as an SF Symbol.
///
/// Uncached deliberately. The lookup is a bundle-backed symbol resolution that AppKit itself
/// caches, it runs once per lane per render pass, and a cache here would be one more piece of
/// main-actor state to keep honest across the OS's own symbol availability. If a profile ever
/// says otherwise, this one function is where the memo goes.
static func exists(_ name: String) -> Bool {
!name.isEmpty && NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
}
}