Files
lanework/Kanban/UI/Board/DragPayload.swift
T
rzen 90cf82d740 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
2026-07-27 20:58:26 -04:00

140 lines
5.3 KiB
Swift

import AppKit
import Foundation
import UniformTypeIdentifiers
// MARK: - The drag types
extension UTType {
/// A drag carrying board **cards** — live card faces or trash rows (04-interactions.md ▸ Drag
/// and drop, ▸ The trash). Declared as an exported type in `Info.plist`, because a system drag
/// session is what crosses window boundaries, shows the copy badge, and gives the full-size
/// replica preview (DRAG-REORDER.md § Cross-board sessions).
static let laneworkCards = UTType(exportedAs: "dev.rzen.indie.kanban.cards")
/// A drag carrying board **lanes** — the strip's own reorder and the cross-board lane transfer.
static let laneworkLanes = UTType(exportedAs: "dev.rzen.indie.kanban.lanes")
}
// MARK: - What a drag carries
/// Which of the board's two levels a drag session carries. The selection is homogeneous by kind
/// (04-interactions.md § Selection), so a session is one or the other and never both.
enum DragKind: String, Codable, Sendable, Equatable {
case cards
case lanes
var type: UTType {
switch self {
case .cards: .laneworkCards
case .lanes: .laneworkLanes
}
}
}
/// The JSON a drag session puts on the pasteboard.
///
/// **Same-app transfer is the only real consumer.** Both boards are open in this app, so the hover
/// path reads identity from `DragSession` — captured synchronously at drag start — and never decodes
/// anything mid-flight; the payload is the formal drop data, and what makes the session a *system*
/// session at all (which is what crosses windows and draws the badge). It carries folder URLs
/// because that is what the destination store's commits take (`receiveCards`, `receiveLanes`), and
/// the source board root beside them because locality — the Finder volume model — is a comparison of
/// roots (04-interactions.md ▸ Drag and drop).
///
/// A **plain-text secondary representation** (the dragged titles, newline-joined) rides along so a
/// stray drop into a text editor does something sane rather than nothing.
struct DragPayload: Codable, Sendable, Equatable {
/// Which side of the live/tombstoned boundary the drag started on — a trash row's drag is a card
/// drag from the trashed side, and 04-interactions.md ▸ The trash gives it its own rules
/// (restore within the board, copy-out across boards).
enum Side: String, Codable, Sendable, Equatable {
case live
case trashed
init(_ liveness: Liveness) {
self = liveness == .live ? .live : .trashed
}
var liveness: Liveness { self == .live ? .live : .trashed }
}
/// One dragged item: its UUID, its folder on disk, and its title for the text representation.
struct Item: Codable, Sendable, Equatable {
var id: String
var folder: String
var title: String?
}
/// The **source** board's root folder — the left-hand side of the locality comparison.
var boardRoot: String
var kind: DragKind
var side: Side
/// The dragged items **in flatten order** — "lane `order` first, then card `order`"
/// (04-interactions.md ▸ Drag and drop). The drop commits trust this order rather than
/// re-deriving it, because a cross-board destination has no flatten order for items it does not
/// hold.
var items: [Item]
var ids: [ItemID] { items.map { ItemID(rawValue: $0.id) } }
var folders: [URL] { items.map { URL(fileURLWithPath: $0.folder, isDirectory: true) } }
var rootURL: URL { URL(fileURLWithPath: boardRoot, isDirectory: true) }
/// The secondary representation: one title per line, untitled items rendered as they are on the
/// board (03-board-ui.md § Card face — "Untitled" is a rendering, never a value).
var plainText: String {
items.map { $0.title ?? "Untitled" }.joined(separator: "\n")
}
// MARK: Coding
func encoded() -> Data? {
try? JSONEncoder().encode(self)
}
init(boardRoot: URL, kind: DragKind, side: Liveness, items: [Item]) {
self.boardRoot = boardRoot.path
self.kind = kind
self.side = Side(side)
self.items = items
}
init?(data: Data) {
guard let decoded = try? JSONDecoder().decode(DragPayload.self, from: data) else { return nil }
self = decoded
}
/// The item provider a `.onDrag` hands back: the JSON under this session's own type, plus the
/// plain-text fallback.
///
/// The custom type is registered `.ownProcess` deliberately — the payload names folders inside
/// the user's boards, and no other app has any business reading it; the *text* is the
/// representation other apps get.
func itemProvider() -> NSItemProvider {
let provider = NSItemProvider()
if let data = encoded() {
provider.registerDataRepresentation(
forTypeIdentifier: kind.type.identifier,
visibility: .ownProcess
) { completion in
completion(data, nil)
return nil
}
}
let text = Data(plainText.utf8)
provider.registerDataRepresentation(
forTypeIdentifier: UTType.utf8PlainText.identifier,
visibility: .all
) { completion in
completion(text, nil)
return nil
}
return provider
}
}