Implement the keyboard grammar and full command map
The board's fixed grammar keys and the menu-backed chords of 04-interactions.md § Keyboard, per the Command Nexus inventory: - Spatial arrow navigation (NavigationMath.nearest over the marquee registry's frames — one geometry source), walking across interior masonry columns, lanes, and into the shown trash; ⇧-arrows extend via the same range function as ⇧-click and go inert at the liveness and kind boundaries; ⌥-jumps with the ⌥↑ lane-domain escalation and ↓ descent; the empty selection seeds at the first lane's first card; selection scrolls into view. - selectionHead — the navigation cursor beside the anchor, set by every click, moved by every arrow, dropped by the reload vanish rule. - Board ▸ Open Card ⌘↩ (the one command enabled mid-edit: commits the placeholder or rename and opens), Move Up/Move Down ⌥⌘↑/⌥⌘↓ (within-lane sort, gather-then-step, rank-permuting writes in one bracket), Move Left/Move Right ⌘←/⌘→ (sole lane, one slot, never the trash) — all validating and acting off one shared answer. - Delete now selects the Finder-style successor sibling from the pre-write snapshot, so repeated ⌫ walks down a lane; external vanishing still only shrinks the selection. - handleReturn rejects modified Returns; the trash column renders eagerly so every row stays registered for navigation and the marquee. 686 unit tests (27 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1101,6 +1101,89 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Within-lane sort
|
||||
|
||||
/// The lane and the new card ordering one ⌥⌘↑/⌥⌘↓ press would produce, or `nil` when the press
|
||||
/// is not available — **the menu items' `disabled` condition and the write's guard, as one
|
||||
/// answer** (`LaneWidthCommands`' rule).
|
||||
///
|
||||
/// `nil` covers every refusal the design names in one expression: an empty or tombstoned
|
||||
/// selection ("⌥⌘↑/⌥⌘↓ are inert *on* tombstoned cards"), a lane selection ("with a lane
|
||||
/// selected … ⌥⌘↑/⌥⌘↓ are inert"), a card selection that **spans lanes** ("cards never change
|
||||
/// lanes by ⌘-arrow … so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes"), and a block
|
||||
/// already at the end of its lane.
|
||||
func sortPlan(_ direction: SortMath.Direction) -> (lane: Lane, ordering: [ItemID])? {
|
||||
let selection = transient.selection
|
||||
guard selection.liveness == .live,
|
||||
SelectionGrammar.kind(of: selection, in: snapshot) == .card,
|
||||
// `nil` here *is* the spans-lanes case: the helper answers only when one lane holds
|
||||
// the whole set.
|
||||
let laneID = Self.lane(holding: selection.ids, in: snapshot),
|
||||
let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
|
||||
else { return nil }
|
||||
|
||||
let rendered = lane.cards.filter { !$0.isDeleted }.map(\.id)
|
||||
guard let ordering = SortMath.reordered(rendered, moving: selection.ids, direction) else { return nil }
|
||||
return (lane, ordering)
|
||||
}
|
||||
|
||||
/// Board ▸ Move Up / Move Down (⌥⌘↑/⌥⌘↓) — the within-lane sort (04-interactions.md ▸ The map).
|
||||
///
|
||||
/// **One `performWrite` bracket**, like every other batch here: one gesture, one app-mediated
|
||||
/// reload, one commit on git boards.
|
||||
///
|
||||
/// **The ranks are permuted, not invented.** The lane's existing `order` values, read in display
|
||||
/// order, are already a sorted ladder of exactly the right length — so the new ordering takes
|
||||
/// them rung for rung and only the cards whose *position* changed are rewritten. A block stepping
|
||||
/// past one sibling therefore touches the block plus that sibling and nothing else, which is what
|
||||
/// keeps `modified` (and, later, a git commit) honest about what actually moved.
|
||||
///
|
||||
/// The one case that ladder cannot serve is **duplicate `order` values**, where display order is
|
||||
/// decided by the folder-name tie-break (`Ranks.isOrderedForDisplay`) rather than by the rank —
|
||||
/// permuting equal ranks would write the file and leave the board looking identical. That is the
|
||||
/// renumber trigger, exactly as an exhausted midpoint is elsewhere: compact the lane, then place
|
||||
/// against the fresh ladder (`commitPlaceholder`'s and `moveLane`'s pattern).
|
||||
///
|
||||
/// The selection, the anchor and the head are deliberately untouched: every id survives, and the
|
||||
/// cards the user is moving should stay the cards the user is moving.
|
||||
public func sortSelection(_ direction: SortMath.Direction) {
|
||||
guard let plan = sortPlan(direction) else { return }
|
||||
|
||||
let rendered = plan.lane.cards.filter { !$0.isDeleted }
|
||||
let laneFolder = rootURL.appendingPathComponent(plan.lane.id.rawValue, isDirectory: true)
|
||||
let orders = rendered.map(\.order)
|
||||
let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) })
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ladder = orders
|
||||
if !Self.isStrictlyAscending(orders) {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
|
||||
// with `rendered` — the same alignment `commitPlaceholder` relies on.
|
||||
ladder = Ranks.renumbered(count: rendered.count)
|
||||
}
|
||||
for (destination, id) in plan.ordering.enumerated() {
|
||||
guard let origin = positions[id], origin != destination else { continue }
|
||||
let rank = ladder[destination]
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: laneFolder.appendingPathComponent(id.rawValue, isDirectory: true),
|
||||
// `.reorder(title: nil)`: `updateIndex` enriches it off the document it reads, so
|
||||
// a failure names the card by its own title.
|
||||
operation: .reorder(title: nil)
|
||||
) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a lane's ranks separate its cards on their own — the condition under which they can
|
||||
/// be permuted rather than replaced. Ties fall to the folder-name tie-break, which a permutation
|
||||
/// cannot reach past.
|
||||
nonisolated static func isStrictlyAscending(_ orders: [Double]) -> Bool {
|
||||
zip(orders, orders.dropFirst()).allSatisfy { $0 < $1 }
|
||||
}
|
||||
|
||||
// MARK: - The trash
|
||||
|
||||
/// Whether physically removing an item on this board destroys the only copy of it — and
|
||||
@@ -1138,22 +1221,34 @@ public final class BoardStore {
|
||||
/// only, so a selection the next reload will drop writes nothing rather than re-stamping a
|
||||
/// `deleted:` that is already there. An empty resolution never opens the bracket at all.
|
||||
///
|
||||
/// The selection is **cleared**, not moved to a successor. 04-interactions.md ▸ The map asks for
|
||||
/// the Finder-style successor sibling ("repeated ⌫ walks down a lane"), which needs the
|
||||
/// navigation order the keyboard grammar defines — that is m5's card. Clearing is the honest
|
||||
/// interim: what was selected renders nowhere now, and the reload's resolve rule would empty the
|
||||
/// set a moment later anyway.
|
||||
/// **The selection moves to the successor sibling** — 04-interactions.md ▸ The map's Finder-style
|
||||
/// rule ("next card in the lane, next lane on the board; the last sibling's predecessor
|
||||
/// otherwise; empty container = nothing selected"), whose whole point is that "repeated ⌫ walks
|
||||
/// down a lane".
|
||||
///
|
||||
/// Two things make that hold. The successor is computed from the **pre-write** snapshot, which is
|
||||
/// the last one that still knows where the doomed items sat; and it is selected **immediately**,
|
||||
/// rather than waiting for the reload the tombstone will echo back — a second ⌫ pressed before
|
||||
/// the watcher rounds the first one back must already have somewhere to land.
|
||||
///
|
||||
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
|
||||
/// reload-survival rule), and neither do `putBack`/`deleteImmediately` — the item merely changed
|
||||
/// sides, or nothing survives on either.
|
||||
public func delete(_ ids: Set<ItemID>) {
|
||||
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
|
||||
guard !folders.isEmpty else { return }
|
||||
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for folder in folders {
|
||||
try BoardWriter.deleteItem(at: folder)
|
||||
}
|
||||
}
|
||||
// m5-keyboard: the successor-selection grammar replaces this line.
|
||||
clearSelection()
|
||||
if let successor {
|
||||
select([successor], liveness: .live, anchor: successor, head: successor)
|
||||
} else {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket
|
||||
@@ -1314,8 +1409,8 @@ public final class BoardStore {
|
||||
/// 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, anchor: ItemID? = nil) {
|
||||
transient.select(ids, liveness: liveness, anchor: anchor)
|
||||
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
|
||||
transient.select(ids, liveness: liveness, anchor: anchor, head: head)
|
||||
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
|
||||
}
|
||||
|
||||
@@ -1342,10 +1437,16 @@ public final class BoardStore {
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
// The anchor is passed through explicitly: `select`'s default would otherwise re-anchor a
|
||||
// Both cursors are passed through explicitly: `select`'s default would otherwise re-anchor a
|
||||
// ⇧-range's single-member edge case on the target, and the grammar's answer is the one that
|
||||
// knows whether this click was an origin or an extension.
|
||||
select(outcome.selection.ids, liveness: outcome.selection.liveness, anchor: outcome.anchor)
|
||||
// knows whether this click was an origin or an extension. The head is the clicked item in
|
||||
// every branch — see `SelectionGrammar.Outcome`.
|
||||
select(
|
||||
outcome.selection.ids,
|
||||
liveness: outcome.selection.liveness,
|
||||
anchor: outcome.anchor,
|
||||
head: outcome.head
|
||||
)
|
||||
}
|
||||
|
||||
/// **Select All** — "all visible cards on the board" (04-interactions.md ▸ The map), with the
|
||||
@@ -1359,9 +1460,9 @@ public final class BoardStore {
|
||||
/// foreign Put Back, a purge) falls through to the board rather than selecting the trash
|
||||
/// wholesale on a guess.
|
||||
///
|
||||
/// The anchor **survives if it is still in the set** and is dropped otherwise: Select All is not
|
||||
/// a click, so it names no new origin, but it has no business discarding one that is still
|
||||
/// standing inside what it selected.
|
||||
/// The anchor — and the navigation head with it — **survives if it is still in the set** and is
|
||||
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
|
||||
/// but it has no business discarding ones that are still standing inside what it selected.
|
||||
///
|
||||
// m5-search: "filter-respecting, like every surface" (04 ▸ The map). The universe here is
|
||||
// `SelectionGrammar`'s order lists, which is where the filter threads in — one change, and both
|
||||
@@ -1376,14 +1477,15 @@ public final class BoardStore {
|
||||
}
|
||||
|
||||
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the
|
||||
/// anchor is kept only while it is still inside what was selected.
|
||||
/// anchor and head are kept only while they are still inside what was selected.
|
||||
private func apply(_ ids: Set<ItemID>, on side: Liveness) {
|
||||
guard !ids.isEmpty else {
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil }
|
||||
select(ids, liveness: side, anchor: anchor)
|
||||
let head = transient.selectionHead.flatMap { ids.contains($0) ? $0 : nil }
|
||||
select(ids, liveness: side, anchor: anchor, head: head)
|
||||
}
|
||||
|
||||
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
|
||||
|
||||
@@ -64,24 +64,31 @@ public enum ClickModifier: Sendable, Equatable {
|
||||
/// trash row) share one answer instead of four near-copies of it.
|
||||
public enum SelectionGrammar {
|
||||
|
||||
/// What a click leaves behind: the new selection, and the anchor a subsequent ⇧-click would
|
||||
/// range from.
|
||||
/// What a click leaves behind: the new selection, the anchor a subsequent ⇧-click would range
|
||||
/// from, and the head a subsequent arrow would step from.
|
||||
///
|
||||
/// The anchor is carried *out* rather than mutated in place because it is not derivable from the
|
||||
/// selection — a ⇧-range replaces the whole set and deliberately leaves the anchor where it was,
|
||||
/// so "the last plain or ⌘ click" is a memory of a gesture and only the gesture can update it
|
||||
/// (`TransientBoardState.selectionAnchor`).
|
||||
///
|
||||
/// **The head is always the clicked item**, in every branch below — the ⇧-branch included, where
|
||||
/// the anchor deliberately stays put. That asymmetry is the definition of the two:
|
||||
/// `TransientBoardState.selectionHead` is where the *next* step starts, and a ⇧-click moves it
|
||||
/// exactly as a plain one does.
|
||||
public struct Outcome: Sendable, Equatable {
|
||||
public var selection: ItemReferenceSet
|
||||
public var anchor: ItemID?
|
||||
public var head: ItemID?
|
||||
|
||||
public init(selection: ItemReferenceSet, anchor: ItemID?) {
|
||||
public init(selection: ItemReferenceSet, anchor: ItemID?, head: ItemID? = nil) {
|
||||
self.selection = selection
|
||||
self.anchor = anchor
|
||||
self.head = head
|
||||
}
|
||||
|
||||
/// Nothing selected and nothing to range from — the toggle-off outcomes.
|
||||
static let cleared = Outcome(selection: .empty, anchor: nil)
|
||||
/// Nothing selected, nothing to range from, nowhere to step from — the toggle-off outcomes.
|
||||
static let cleared = Outcome(selection: .empty, anchor: nil, head: nil)
|
||||
}
|
||||
|
||||
/// The grammar, one call.
|
||||
@@ -132,7 +139,8 @@ public enum SelectionGrammar {
|
||||
}
|
||||
return Outcome(
|
||||
selection: ItemReferenceSet(ids: [target.id], liveness: target.side),
|
||||
anchor: target.id
|
||||
anchor: target.id,
|
||||
head: target.id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,7 +173,8 @@ public enum SelectionGrammar {
|
||||
}
|
||||
return Outcome(
|
||||
selection: ItemReferenceSet(ids: ids, liveness: target.side),
|
||||
anchor: target.id
|
||||
anchor: target.id,
|
||||
head: target.id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,20 +193,43 @@ public enum SelectionGrammar {
|
||||
anchor: ItemID?,
|
||||
snapshot: BoardModel
|
||||
) -> Outcome {
|
||||
let list = order(of: target.kind, on: target.side, in: snapshot)
|
||||
guard let anchor,
|
||||
let from = list.firstIndex(of: anchor),
|
||||
let to = list.firstIndex(of: target.id)
|
||||
let span = range(from: anchor, to: target.id, kind: target.kind, on: target.side, in: snapshot)
|
||||
else {
|
||||
return plain(target, selection: selection, togglesOnRepeat: false)
|
||||
}
|
||||
let span = from <= to ? list[from...to] : list[to...from]
|
||||
return Outcome(
|
||||
selection: ItemReferenceSet(ids: Set(span), liveness: target.side),
|
||||
anchor: anchor
|
||||
selection: ItemReferenceSet(ids: span, liveness: target.side),
|
||||
anchor: anchor,
|
||||
head: target.id
|
||||
)
|
||||
}
|
||||
|
||||
/// The ids between two items in one order list, inclusive — **the span both extension gestures
|
||||
/// select**, ⇧-click and ⇧-arrow alike.
|
||||
///
|
||||
/// It is a function rather than a branch inside `shift` because the keyboard needs the identical
|
||||
/// answer: "a ⇧-arrow extends" (04-interactions.md ▸ Grammar) means exactly the range a ⇧-click
|
||||
/// to the same item would produce, and two implementations of one span is two chances for the
|
||||
/// pointer and the keyboard to disagree about what a range is.
|
||||
///
|
||||
/// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil
|
||||
/// anchor's caller-side absence, and every axis crossing into one test — a list is exactly one
|
||||
/// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain
|
||||
/// click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is
|
||||
/// ambiguous).
|
||||
public static func range(
|
||||
from: ItemID,
|
||||
to: ItemID,
|
||||
kind: SelectionKind,
|
||||
on side: Liveness,
|
||||
in snapshot: BoardModel
|
||||
) -> Set<ItemID>? {
|
||||
let list = order(of: kind, on: side, in: snapshot)
|
||||
guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil }
|
||||
return Set(start <= end ? list[start...end] : list[end...start])
|
||||
}
|
||||
|
||||
// MARK: - The order lists
|
||||
|
||||
/// The list a ⇧-range walks for one (side, kind) pair — **the single place a "what's on the
|
||||
@@ -284,6 +316,55 @@ public enum SelectionGrammar {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Successor on delete
|
||||
|
||||
/// What ⌫ selects after tombstoning `ids` — 04-interactions.md ▸ The map's Finder-style
|
||||
/// successor sibling, as a pure function of the **pre-write** snapshot.
|
||||
///
|
||||
/// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the
|
||||
/// > lane, next lane on the board; the last sibling's predecessor otherwise; empty container =
|
||||
/// > nothing selected) — repeated ⌫ walks down a lane.
|
||||
///
|
||||
/// Three decisions the wording implies and this states:
|
||||
///
|
||||
/// - **The container is the *last* deleted item's**, in flatten order — the same "last member"
|
||||
/// the ⌘N target rule and paste anchoring already share. A selection spanning lanes therefore
|
||||
/// lands in the rightmost/bottom-most one, which is where the user was working.
|
||||
/// - **The survivor search is forward first, then backward**: the first surviving sibling *after*
|
||||
/// the last deleted position, else the last surviving sibling *before* the first deleted one.
|
||||
/// Forward is what makes repeated ⌫ walk down a lane rather than bouncing.
|
||||
/// - **`nil` is a legitimate answer** — an emptied container selects nothing, and the caller
|
||||
/// clears.
|
||||
///
|
||||
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
|
||||
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
|
||||
/// `BoardStore.delete` and by nothing on the reload path.
|
||||
public static func successor(afterDeleting ids: Set<ItemID>, in snapshot: BoardModel) -> ItemID? {
|
||||
guard !ids.isEmpty else { return nil }
|
||||
let selection = ItemReferenceSet(ids: ids, liveness: .live)
|
||||
guard let kind = kind(of: selection, in: snapshot) else { return nil }
|
||||
|
||||
let container: [ItemID]
|
||||
switch kind {
|
||||
case .lane:
|
||||
container = liveLanes(in: snapshot)
|
||||
case .card:
|
||||
// The last selected card in flatten order names the lane; its lane's rendered cards are
|
||||
// the container the successor is drawn from.
|
||||
guard let last = liveCards(in: snapshot).last(where: { ids.contains($0) }),
|
||||
let lane = snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted }
|
||||
})
|
||||
else { return nil }
|
||||
container = lane.cards.filter { !$0.isDeleted }.map(\.id)
|
||||
}
|
||||
|
||||
let doomed = container.indices.filter { ids.contains(container[$0]) }
|
||||
guard let first = doomed.first, let last = doomed.last else { return nil }
|
||||
if let after = container[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
|
||||
return container[..<first].last { !ids.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The rubber band
|
||||
@@ -344,7 +425,10 @@ public enum MarqueeMath {
|
||||
///
|
||||
/// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order
|
||||
/// the same way twice, or the topmost-kind rule would pick differently on identical input.
|
||||
private static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
|
||||
///
|
||||
/// Shared with `NavigationMath`, which breaks its score ties with it for the same reason: two
|
||||
/// candidates that a metric cannot separate must still be separated the same way twice.
|
||||
static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
|
||||
if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY }
|
||||
if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
|
||||
return lhs.id.rawValue < rhs.id.rawValue
|
||||
|
||||
@@ -295,6 +295,19 @@ public final class TransientBoardState {
|
||||
/// the same rule every other item reference gets.
|
||||
public private(set) var selectionAnchor: ItemID?
|
||||
|
||||
/// **Where the next arrow steps from** — the navigation cursor, AppKit's "lead": the item the
|
||||
/// last click or arrow named (04-interactions.md ▸ Grammar's spatial navigation).
|
||||
///
|
||||
/// **Distinct from the anchor, and the difference is the whole reason both exist.** A ⇧-gesture
|
||||
/// leaves the anchor exactly where it was — that is what makes successive extensions sweep out
|
||||
/// from one origin — while the *head* walks to whatever was just reached, because the next
|
||||
/// ⇧-arrow has to continue from there rather than from the origin. A plain click or arrow moves
|
||||
/// both; a ⇧-click or ⇧-arrow moves only this.
|
||||
///
|
||||
/// A memory of a gesture like the anchor, on the selection's side by construction, and re-grounded
|
||||
/// by `resolve(against:)` under the same universe rule.
|
||||
public private(set) var selectionHead: ItemID?
|
||||
|
||||
/// The items a drag is carrying — **empty when no drag is in flight**, which is what "no drag"
|
||||
/// means here rather than a separate flag.
|
||||
///
|
||||
@@ -391,27 +404,34 @@ public final class TransientBoardState {
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
/// Replaces the selection, and sets the anchor a subsequent ⇧-click ranges from.
|
||||
/// Replaces the selection, and sets the anchor a subsequent ⇧-click ranges from and the head a
|
||||
/// subsequent arrow steps from.
|
||||
///
|
||||
/// The grammar itself is `SelectionGrammar`'s — pure, testable, and the one funnel every click
|
||||
/// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its
|
||||
/// own is the **anchor default**: `nil` with a sole member anchors on that member, `nil` with
|
||||
/// any other count anchors on nothing. That makes the two callers that pass nothing behave
|
||||
/// exactly as they should — a one-item selection made by any route is a legitimate range origin,
|
||||
/// while a marquee or a Select All names no click and so leaves a ⇧-click acting plain.
|
||||
/// own is the **anchor default**, which the head shares: `nil` with a sole member takes that
|
||||
/// member, `nil` with any other count takes nothing. That makes the two callers that pass
|
||||
/// nothing behave exactly as they should — a one-item selection made by any route is a
|
||||
/// legitimate range origin *and* a legitimate place to arrow from, while a marquee or a Select
|
||||
/// All names no gesture and so leaves a ⇧-click acting plain and the arrows re-deriving a
|
||||
/// position from the set's last member.
|
||||
///
|
||||
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
|
||||
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
|
||||
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
|
||||
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
|
||||
selection = ItemReferenceSet(ids: ids, liveness: liveness)
|
||||
selectionAnchor = anchor ?? (ids.count == 1 ? ids.first : nil)
|
||||
let sole = ids.count == 1 ? ids.first : nil
|
||||
selectionAnchor = anchor ?? sole
|
||||
selectionHead = head ?? sole
|
||||
}
|
||||
|
||||
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). The anchor goes
|
||||
/// with it: an empty selection has no origin to range from.
|
||||
/// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). The anchor and
|
||||
/// the head go with it: an empty selection has no origin to range from and no cursor to step
|
||||
/// from — which is exactly the state the arrows' seed rule answers.
|
||||
public func clearSelection() {
|
||||
selection = .empty
|
||||
selectionAnchor = nil
|
||||
selectionHead = nil
|
||||
}
|
||||
|
||||
/// Records that `laneID` is where the user is working — a lane selected, or created into.
|
||||
@@ -597,15 +617,18 @@ public final class TransientBoardState {
|
||||
if let lane = lastActiveLaneID, !live.contains(lane) {
|
||||
lastActiveLaneID = nil
|
||||
}
|
||||
if let anchor = selectionAnchor {
|
||||
// The selection's side, because that is the side the anchor lives on by construction —
|
||||
// every route that sets it sets the selection to the same side in the same call. A
|
||||
// vanished or liveness-flipped anchor is gone, which is the rule every item reference
|
||||
// here gets: "a flip is a vanish from its side of the boundary".
|
||||
if selectionAnchor != nil || selectionHead != nil {
|
||||
// The selection's side, because that is the side both cursors live on by construction —
|
||||
// every route that sets either sets the selection to the same side in the same call. A
|
||||
// vanished or liveness-flipped cursor is gone, which is the rule every item reference
|
||||
// here gets: "a flip is a vanish from its side of the boundary". The head then re-derives
|
||||
// from the selection's last member on the next arrow, which is the same fallback an
|
||||
// anchorless ⇧-arrow already uses.
|
||||
let universe = selection.liveness == .live
|
||||
? live
|
||||
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
|
||||
if !universe.contains(anchor) { selectionAnchor = nil }
|
||||
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
|
||||
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user