Realign the Cmd-N target rule — a multi-selection anchors at its last member
The corpus just ratified the plural case: a multi-selection anchors at its last member in flatten order (lane order, then card order — the multi-drag order, the same anchor paste will use), so creation follows the last selected card or appends to the last selected lane instead of falling through to the last-active lane. One display-order walk keeps the resolver pure; the sole selection is now just the degenerate one-member case. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -5,15 +5,18 @@
|
|||||||
///
|
///
|
||||||
/// > with a card selected, the new card is created in that card's lane, immediately after it
|
/// > 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);
|
/// > (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
|
/// > a multi-selection anchors at its last member in flatten order (lane `order`, then card
|
||||||
/// > **last-active lane** — the lane that most recently held selection or a creation in this window
|
/// > `order`, the multi-drag order; the same anchor serves paste): creation follows the last
|
||||||
/// > session — falling back to the first lane. … **Zero-lane board**: card creation … disable[s] via
|
/// > selected card, or appends to the last selected lane; … with nothing selected — or a
|
||||||
/// > menu validation until a lane exists.
|
/// > **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
|
/// **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,
|
/// that can be one is: the branches are lines of test rather than UI states to drive, and the menu
|
||||||
/// and the menu item's `disabled` and its action then read the *same* answer instead of two
|
/// item's `disabled` and its action then read the *same* answer instead of two hand-kept-in-sync
|
||||||
/// hand-kept-in-sync conditions.
|
/// conditions.
|
||||||
///
|
///
|
||||||
/// ### What it deliberately does not decide
|
/// ### What it deliberately does not decide
|
||||||
///
|
///
|
||||||
@@ -22,10 +25,6 @@
|
|||||||
/// call site passes its own lane and never comes here.
|
/// 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
|
/// - **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.
|
/// 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 {
|
enum NewCardTarget {
|
||||||
|
|
||||||
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
|
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
|
||||||
@@ -57,23 +56,33 @@ enum NewCardTarget {
|
|||||||
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
||||||
guard !lanes.isEmpty else { return nil }
|
guard !lanes.isEmpty else { return nil }
|
||||||
|
|
||||||
if selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first {
|
if selection.liveness == .live, !selection.ids.isEmpty {
|
||||||
|
// The last selected member in flatten order — lane `order`, then card `order`, the
|
||||||
|
// multi-drag order (04, settled; the same anchor serves paste). The snapshot's lanes
|
||||||
|
// and cards are already in display order, so the flatten order is one walk, and the
|
||||||
|
// *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only one of
|
||||||
|
// the two branches ever fires within a walk; a sole selection is simply the degenerate
|
||||||
|
// one-member case of the same rule.
|
||||||
|
var anchor: Resolution?
|
||||||
for lane in lanes {
|
for lane in lanes {
|
||||||
// A lane selected: appended at its bottom, Return consistency.
|
// A selected lane: creation appends at its bottom, Return consistency.
|
||||||
if lane.id == id { return Resolution(laneID: lane.id, anchorCardID: nil) }
|
if selection.ids.contains(lane.id) {
|
||||||
// A card selected: its lane, immediately after it — paste-anchor consistency.
|
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
|
||||||
if lane.cards.contains(where: { $0.id == id && !$0.isDeleted }) {
|
}
|
||||||
return Resolution(laneID: lane.id, anchorCardID: id)
|
// A selected card: its lane, immediately after it — paste-anchor consistency.
|
||||||
|
for card in lane.cards where !card.isDeleted && selection.ids.contains(card.id) {
|
||||||
|
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The id names nothing the board renders — a selection the next reload will drop.
|
if let anchor { return anchor }
|
||||||
|
// The ids name 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
|
// Falls through to the last-active lane rather than refusing: the user pressed ⌘N and
|
||||||
// the board has lanes.
|
// the board has lanes.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing selected, a tombstoned selection, a multi-selection, or a stale one: the lane that
|
// Nothing selected, a tombstoned selection, or a stale one: the lane that most recently
|
||||||
// most recently held selection or a creation, and the first lane when there is no such lane
|
// held selection or a creation, and the first lane when there is no such lane (or it has
|
||||||
// (or it has since gone).
|
// since gone).
|
||||||
if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) {
|
if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) {
|
||||||
return Resolution(laneID: lane.id, anchorCardID: nil)
|
return Resolution(laneID: lane.id, anchorCardID: nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,16 +158,32 @@ struct NewCardTargetTests {
|
|||||||
#expect(resolve(snapshot) == nil)
|
#expect(resolve(snapshot) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A multi-selection and a stale one both fall through rather than guessing")
|
@Test("A multi-selection anchors at its last member in flatten order")
|
||||||
func pluralAndStaleSelectionsFallThrough() throws {
|
func pluralSelectionsAnchorAtTheirLastMember() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
// The rule speaks of "a card"/"a lane", singular; a multi-selection has no "it" to be
|
// "A multi-selection anchors at its last member in flatten order (lane `order`, then card
|
||||||
// immediately after, so it gets the same answer as no selection at all.
|
// `order`, the multi-drag order)": card3 lives in lane2, which sorts after lane1's pair, so
|
||||||
|
// creation follows card3 — the last-active lane never enters into it.
|
||||||
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], liveness: .live), lastActive: lane1)
|
||||||
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
||||||
|
|
||||||
|
// Within one lane the flatten order is card order: card2 sorts after card1.
|
||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], liveness: .live), lastActive: lane2)
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], liveness: .live), lastActive: lane2)
|
||||||
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
|
||||||
|
|
||||||
|
// A multi-LANE selection appends to the last selected lane's bottom.
|
||||||
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], liveness: .live))
|
||||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A stale selection falls through rather than guessing or refusing")
|
||||||
|
func staleSelectionsFallThrough() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
// A selection naming something the board does not render — the reload that drops it has not
|
// A selection naming something the board does not render — the reload that drops it has not
|
||||||
// landed yet — must not refuse the creation the user just asked for.
|
// landed yet — must not refuse the creation the user just asked for.
|
||||||
|
|||||||
Reference in New Issue
Block a user