Implement drag & drop with the locality model
The second half: system drag sessions over phase 1's model, per DRAG-REORDER.md and 04-interactions.md § Drag & drop. - Card faces, lane headers, and trash rows drag as NSItemProvider sessions (two exported UTTypes, JSON payload in flatten order, plain-text titles as the secondary representation) — replacing m4's custom lane-reorder gesture and trash drag-out wholesale; the app-wide DragSession carries the members, the frozen dragged sizes, the live proposal, and the effective operation. - Three drop delegates (lane masonry, strip, window fallback), each accepting both types and routing internally per the single-target-dispatch rule; the cursor is the physical mouse converted to strip space; proposals come from DropSlotMath with hysteresis threaded through, and the lane-strip proposal clamps in front of the shown trash. - Locality picks the default — move within a board, copy across, the badge tracking live; ⌥ forces copy (ignored on within-board lane drags), ⌘ forces move; trash rows restore within their board (positional), copy out across boards by default, ⌘ forcing the true restore-move. - N contiguous shadows with reflow keyed on the proposal; the committed-overlay hold renders the dropped arrangement until the reload echo lands (1.5 s dissolution deadline for refused writes); the re-grounding trio: geometry re-derives per render, proposals re-validate by liveness at release, an emptied drag cancels itself. - Edge autoscroll (ticking driver over DragAutoScrollMath, re-targeting per step), the mouse-up-gated late-event cleanup, and the polling watchdog — the pathfinder's lifecycle traps, ported. - Store: moveLanes and multi-card restoreByDrag join the one-bracket drop commits. 784 unit tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The drag session's **value** halves — the pasteboard payload, the locality model, and the
|
||||
/// committed overlay's hand-off condition (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// The session object itself, the drop delegates and the gestures are not unit-testable — they are
|
||||
/// deliberately thin over these three, plus `DropSlotMath`'s arithmetic, which is why the split falls
|
||||
/// where it does.
|
||||
|
||||
// MARK: - The payload
|
||||
|
||||
@Suite("DragPayload")
|
||||
struct DragPayloadTests {
|
||||
|
||||
private static func payload(kind: DragKind = .cards, side: Liveness = .live) -> DragPayload {
|
||||
DragPayload(
|
||||
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
|
||||
kind: kind,
|
||||
side: side,
|
||||
items: [
|
||||
DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
|
||||
DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A payload round-trips through its JSON representation unchanged")
|
||||
func roundTrip() throws {
|
||||
for kind in [DragKind.cards, .lanes] {
|
||||
for side in [Liveness.live, .trashed] {
|
||||
let original = Self.payload(kind: kind, side: side)
|
||||
let data = try #require(original.encoded())
|
||||
#expect(DragPayload(data: data) == original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Garbage decodes to nothing rather than to an empty drag")
|
||||
func garbageDecodesToNil() {
|
||||
#expect(DragPayload(data: Data("not json".utf8)) == nil)
|
||||
#expect(DragPayload(data: Data()) == nil)
|
||||
}
|
||||
|
||||
@Test("The ids, folders and root are read back off the strings, in flatten order")
|
||||
func derivedValues() {
|
||||
let payload = Self.payload()
|
||||
#expect(payload.ids == [ItemID(rawValue: "aaa"), ItemID(rawValue: "bbb")])
|
||||
#expect(payload.folders.map(\.path) == [
|
||||
"/Boards/Work.kanban/lane/aaa",
|
||||
"/Boards/Work.kanban/lane/bbb"
|
||||
])
|
||||
#expect(payload.rootURL.path == "/Boards/Work.kanban")
|
||||
}
|
||||
|
||||
@Test("The plain-text representation is the dragged titles, one per line")
|
||||
func plainText() {
|
||||
// The stray-drop-into-a-text-editor fallback. An untitled item renders as the board renders
|
||||
// it — "Untitled" is a rendering, never a value (03-board-ui.md § Card face).
|
||||
#expect(Self.payload().plainText == "First\nUntitled")
|
||||
}
|
||||
|
||||
@Test("The side survives the round trip, because it is what makes a trash drag a trash drag")
|
||||
func sideSurvives() throws {
|
||||
let data = try #require(Self.payload(side: .trashed).encoded())
|
||||
#expect(DragPayload(data: data)?.side.liveness == .trashed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Locality
|
||||
|
||||
@Suite("DragLocality")
|
||||
struct DragLocalityTests {
|
||||
|
||||
private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
||||
private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
|
||||
|
||||
private static let none: NSEvent.ModifierFlags = []
|
||||
private static let option: NSEvent.ModifierFlags = [.option]
|
||||
private static let command: NSEvent.ModifierFlags = [.command]
|
||||
|
||||
@Test("Roots compare by their standardized path, so the same board is the same board")
|
||||
func rootComparison() {
|
||||
#expect(DragLocality.isSameBoard(here, here))
|
||||
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/./Work.kanban/")))
|
||||
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/Other/../Work.kanban")))
|
||||
#expect(!DragLocality.isSameBoard(here, there))
|
||||
}
|
||||
|
||||
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
|
||||
@Test("Locality picks the default — within is a move, across is a copy")
|
||||
func theDefault() {
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: none) == .move)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: none) == .copy)
|
||||
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: none) == .copy)
|
||||
}
|
||||
|
||||
@Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
|
||||
func modifiersOverride() {
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: option) == .copy)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: command) == .move)
|
||||
// The no-ops.
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: command) == .move)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: option) == .copy)
|
||||
}
|
||||
|
||||
@Test("⌘ wins over ⌥ when both are held")
|
||||
func commandWinsOverOption() {
|
||||
// Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
|
||||
#expect(DragLocality.operation(
|
||||
kind: .cards, side: .live, isWithinBoard: false, modifiers: [.option, .command]) == .move)
|
||||
}
|
||||
|
||||
/// The first carve-out: "Lane drags never copy *within their board*. ⌥ is simply ignored there:
|
||||
/// the drag stays a clean reorder and the badge never shows copy."
|
||||
@Test("A within-board lane drag ignores ⌥ entirely")
|
||||
func laneDragsNeverCopyWithinTheirBoard() {
|
||||
for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
|
||||
#expect(
|
||||
DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: true, modifiers: modifiers) == .move,
|
||||
"a within-board lane drag is a reorder whatever is held"
|
||||
)
|
||||
}
|
||||
// Across boards the lane obeys the ordinary grammar again.
|
||||
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: option) == .copy)
|
||||
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: command) == .move)
|
||||
}
|
||||
|
||||
/// The second: a trash row's drag is copy-out grammar (04-interactions.md ▸ The trash). Within its
|
||||
/// own board the default is the restore — a move, no badge; across boards the default is the live
|
||||
/// copy that leaves the tombstone standing. ⌘ forces the true restore-move either way, and ⌥ the
|
||||
/// live copy either way.
|
||||
@Test("A trash row drags as a restore at home and as a copy-out abroad")
|
||||
func trashDragDefaults() {
|
||||
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: none) == .move)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: false, modifiers: none) == .copy)
|
||||
#expect(DragLocality.operation(
|
||||
kind: .cards, side: .trashed, isWithinBoard: false, modifiers: command) == .move)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: option) == .copy)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The committed-overlay hold
|
||||
|
||||
@Suite("CommittedHold")
|
||||
struct CommittedHoldTests {
|
||||
|
||||
private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
||||
private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
|
||||
|
||||
private static let hold = CommittedHold(boardRoot: here, generation: 7)
|
||||
|
||||
@Test("The hold stands until the destination board applies a *newer* snapshot")
|
||||
func retiredByTheNextSnapshot() {
|
||||
// The generation at the commit is the one already on screen — it is the pre-drop arrangement,
|
||||
// and retiring on it would drop the overlay before the write has round-tripped.
|
||||
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 7))
|
||||
#expect(Self.hold.isRetired(byRoot: Self.here, generation: 8))
|
||||
// *Any* snapshot hands off, not just the app-mediated echo: a foreign one that lands first
|
||||
// re-grounds everything anyway.
|
||||
#expect(Self.hold.isRetired(byRoot: Self.here, generation: 99))
|
||||
}
|
||||
|
||||
@Test("A reload on another board says nothing about this one")
|
||||
func otherBoardsDoNotRetireIt() {
|
||||
#expect(!Self.hold.isRetired(byRoot: Self.there, generation: 99))
|
||||
}
|
||||
|
||||
@Test("The board is matched by identity, not by string")
|
||||
func rootMatchingUsesTheLocalityComparison() {
|
||||
#expect(Self.hold.isRetired(byRoot: URL(fileURLWithPath: "/Boards/./Work.kanban/"), generation: 8))
|
||||
}
|
||||
|
||||
@Test("A stale generation never retires it")
|
||||
func staleGenerations() {
|
||||
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 0))
|
||||
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 6))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user