Materialize the trash — faces, menus, and grammar
Phase 3 finishes the pivot at the surface. One card face serves two containers: CardFaceView extracted with a role — board or trash — so stripe, tint, chip, selection stroke, cut dim, marquee registration, and drag are shared by construction, the trash side differing only in its absences: no Open, no rename, no Style, no file-hover highlight, and a Delete that goes through the confirmation host. The column rewrote around the lanes' own single-column masonry so drag reflow reads as positional slides; chrome stays the hatched header, symbol, and count — 11 gives Empty Trash to the File menu alone. Two real grammar bugs die here: plain Backspace on a trash selection purged without the confirmation the menu raises, and the context menu's Delete resolved against the standing selection, so right-clicking a trash card under a board selection silently did nothing — it now stages the clicked set explicitly. Open, Rename, Style, and Empty Trash validation became testable store seams; the column is one named accessibility container of ordinary card elements. The tombstone era is swept: deleteItem, restoreItem, stripTombstonedChildren — dead since lane copies stopped nesting trash — the restore verb, the unreachable put-back banner row, and every quasi-lane doc comment. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -473,9 +473,9 @@ struct LaneView: View {
|
||||
CardFaceView(
|
||||
store: store,
|
||||
card: card,
|
||||
role: .board(openCard: openCard),
|
||||
marquee: marquee,
|
||||
drops: drops,
|
||||
openCard: openCard
|
||||
drops: drops
|
||||
)
|
||||
case let .placeholder(phase):
|
||||
NewCardStubView(store: store, phase: phase, openCard: openCard)
|
||||
@@ -487,7 +487,7 @@ struct LaneView: View {
|
||||
}
|
||||
}
|
||||
// "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
|
||||
// (03-board-ui.md § Motion), which is how a create, a delete, a Put Back and
|
||||
// (03-board-ui.md § Motion), which is how a create, a delete, a restore and
|
||||
// (m5) a search filter's leavers all reach the masonry. The placeholder wears it
|
||||
// too: it is the card, one round trip early. Whether any of it *performs* is
|
||||
// decided upstream — at the reload for the real cards (`Motion.reloadAnimates`),
|
||||
@@ -636,10 +636,10 @@ struct LaneView: View {
|
||||
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.
|
||||
/// **A deleted card renders nowhere, and needs no rule to**: deletion is a *move* into `.trash/`
|
||||
/// (03-board-ui.md § Trash, resettled 2026-07-28), so a deleted card has physically left
|
||||
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
|
||||
/// effective-liveness predicate are retired with the flag they read.
|
||||
///
|
||||
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
|
||||
/// of the resting layout at pickup and stays out until release *whatever the effective operation
|
||||
@@ -811,423 +811,6 @@ enum LaneSlot: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card plate's metrics
|
||||
|
||||
/// The card plate's geometry, spelled once because **two views draw it**: the real face
|
||||
/// (`CardFaceView`) and the placeholder standing in for a card on its way (`NewCardStubView`'s
|
||||
/// `.awaitingArrival` face). 02-architecture.md ▸ TransientBoardState ▸ overlays makes the handoff
|
||||
/// "read as one arrival — the placeholder renders at the arriving card's exact geometry/chrome", and
|
||||
/// exact is only checkable if there is one set of numbers rather than two that happen to agree.
|
||||
private enum CardFaceMetrics {
|
||||
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
|
||||
/// of the card's edge rather than a bar laid over it.
|
||||
static let cornerRadius: CGFloat = 8
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether
|
||||
/// or not a stripe paints, so colouring a card never shifts its title.
|
||||
static let stripeWidth: CGFloat = 4
|
||||
/// The plate's inset around its content.
|
||||
static let contentPadding: CGFloat = 10
|
||||
/// Between the icon, the title and the attachments chip.
|
||||
static let rowSpacing: CGFloat = 6
|
||||
}
|
||||
|
||||
// MARK: - Card face
|
||||
|
||||
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
|
||||
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
|
||||
/// (03-board-ui.md § Card face, § Styling ▸ Capabilities).
|
||||
///
|
||||
/// ### Title-only, deliberately
|
||||
///
|
||||
/// **No body excerpt** — settled, "the face stays title-only … the old 'iterate on the card face
|
||||
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
|
||||
/// indicator when the card has files — the title dominates", which is why the paperclip is a
|
||||
/// secondary-tinted caption and not a count pill: the eye should land on the title.
|
||||
///
|
||||
/// ### Two lenient fields, two different fallbacks
|
||||
///
|
||||
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
|
||||
/// no** — the app never offers a picker for it, but honours what an author writes). Both degrade
|
||||
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
|
||||
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
|
||||
/// way — to **no stripe at all** — because there is no sensible default colour for "the author
|
||||
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
|
||||
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
|
||||
///
|
||||
/// ### One presentation, selection styling only
|
||||
///
|
||||
/// The face is a top-aligned title row and its two decorations — the accent stripe and the
|
||||
/// selection stroke — are shapes in overlays. **A card has one presentation** (resettled
|
||||
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
|
||||
/// face's styling — the selection stroke below — and never its geometry, so the masonry never
|
||||
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
|
||||
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
|
||||
/// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the
|
||||
/// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
|
||||
/// face).
|
||||
private struct CardFaceView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let card: Card
|
||||
|
||||
/// The strip's rubber band — the registry this face registers its drawn frame into.
|
||||
let marquee: MarqueeControl
|
||||
|
||||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||
let drops: BoardDropContext
|
||||
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||||
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
|
||||
/// draw this same plate for the create handoff to read as one arrival.
|
||||
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
|
||||
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||||
/// treatment shootout).
|
||||
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
|
||||
|
||||
var body: some View {
|
||||
titleRow
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
|
||||
// colouring a card never shifts its title relative to its uncoloured neighbours.
|
||||
.padding(.leading, stripeWidth)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
|
||||
// highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is
|
||||
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
|
||||
// hovered card that is *also* selected still reads as the target.
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(
|
||||
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||
lineWidth: isFileHovered ? 2.5 : 1.5
|
||||
)
|
||||
)
|
||||
// The deferred cut's dim (04-interactions.md ▸ Clipboard: "cut items dim in place until paste
|
||||
// moves them"). Above `contentShape` so the face stays fully clickable while it waits.
|
||||
.cutTreatment(of: card.id, in: store)
|
||||
.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. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
||||
// `SelectionGrammar`'s, reached through the store's one funnel.
|
||||
.onTapGesture {
|
||||
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
|
||||
}
|
||||
// "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
|
||||
//
|
||||
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
|
||||
// second tap recogniser on the same view makes the single click *wait* to see whether a
|
||||
// second one arrives, and selection must stay instant. Simultaneous means the first click
|
||||
// of the pair selects and the second opens — Finder's own behaviour.
|
||||
//
|
||||
// **Plain only.** ⌘ and ⇧ double-clicks are selection gestures that happened twice; opening
|
||||
// a window out from under a range the user is still building would be a surprise.
|
||||
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||||
guard ClickModifier.current == .plain else { return }
|
||||
openCard(card.id)
|
||||
})
|
||||
// **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag`
|
||||
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
|
||||
// the session off until the pointer really moves, so selecting and opening stay instant.
|
||||
.onDrag(startCardDrag, preview: { dragReplica })
|
||||
// The card's height, for the drop model's analytic resting grid. A height is content-driven
|
||||
// and does not animate under the reflow — only positions do, and those are never measured
|
||||
// (`LaneDropRegistry`).
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
|
||||
.contextMenu { cardMenu }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card drag
|
||||
|
||||
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
|
||||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||||
private func startCardDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let snapshot = store.snapshot
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.container == .board
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [card.id]
|
||||
|
||||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||||
// component.
|
||||
var lanesByCard: [ItemID: ItemID] = [:]
|
||||
var titles: [ItemID: String] = [:]
|
||||
for lane in snapshot.lanes {
|
||||
for member in lane.cards where ids.contains(member.id) {
|
||||
lanesByCard[member.id] = lane.id
|
||||
titles[member.id] = member.title.value
|
||||
}
|
||||
}
|
||||
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
|
||||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
container: .board,
|
||||
items: ordered.compactMap { id in
|
||||
guard let laneID = lanesByCard[id] else { return nil }
|
||||
return DragPayload.Item(
|
||||
id: id.rawValue,
|
||||
folder: root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
.path,
|
||||
title: titles[id]
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
ordered,
|
||||
folders: payload.folders,
|
||||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||
container: .board,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||||
/// editor and no geometry observers.
|
||||
private var replicaFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.padding(10)
|
||||
.padding(.leading, stripeWidth)
|
||||
.frame(width: 220, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
}
|
||||
|
||||
// MARK: - Context menu
|
||||
|
||||
/// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
|
||||
/// menus' Card row, in its order, complete as of m5.
|
||||
@ViewBuilder
|
||||
private var cardMenu: some View {
|
||||
// Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
|
||||
// alone — "a card window is tied to one card" (11-command-nexus.md), so unlike Style… and
|
||||
// Delete below it, this row never widens to the selection; Open never opens multiple, even
|
||||
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
|
||||
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
|
||||
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
|
||||
// first — only the plain open.
|
||||
Button("Open") {
|
||||
openCard(card.id)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
|
||||
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
|
||||
// this card to be the *sole* selection; a context menu already names its target by where it
|
||||
// was invoked, so — standard macOS practice — it acts on the clicked card outright.
|
||||
Button("Rename") {
|
||||
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||||
|
||||
Divider()
|
||||
|
||||
// Delete: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
|
||||
// widened target set below (`targetIDs`) — the successor-selection rule is `delete(_:)`'s own,
|
||||
// so this row gets it for free.
|
||||
Button("Delete") {
|
||||
store.delete(targetIDs)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
|
||||
/// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
|
||||
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
|
||||
private var styleTarget: StyleTarget {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||
return .items([card.id])
|
||||
}
|
||||
return .items(store.selection.ids)
|
||||
}
|
||||
|
||||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||||
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
|
||||
/// on the live side).
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||
return [card.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
}
|
||||
|
||||
// MARK: - Title row
|
||||
|
||||
private var titleRow: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
titleOrEditor
|
||||
// The title takes the row's width so the indicator sits hard against the trailing
|
||||
// edge — and so the rename field fills the same span the title occupied.
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
}
|
||||
|
||||
/// The title, or the rename editor when this card is the rename target. Unchanged from the
|
||||
/// stub this face replaces: the four exits and their store calls are 04-interactions.md ▸
|
||||
/// Grammar's, stated once in `InlineTitleField`.
|
||||
@ViewBuilder
|
||||
private var titleOrEditor: some View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
|
||||
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary — the tint
|
||||
/// exists to make a *hand-coloured* icon stand out from its neighbours.
|
||||
private var iconTint: AnyShapeStyle {
|
||||
if let color = Palette.color(for: card.iconColor) {
|
||||
AnyShapeStyle(color)
|
||||
} else {
|
||||
AnyShapeStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// The one face chip in scope — shown only when the card actually has files, and quiet enough
|
||||
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
|
||||
/// accessibility label rather than onto the face: it is useful to know, not to look at.
|
||||
@ViewBuilder
|
||||
private var attachmentsIndicator: some View {
|
||||
if !card.attachments.isEmpty {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("\(card.attachments.count) attachments")
|
||||
}
|
||||
}
|
||||
|
||||
/// K1 · left edge stripe, painted with the resolved `background` — "a card's [colour paints] a
|
||||
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
|
||||
/// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// A value that resolves to nothing — a typo'd palette name, a malformed hex, a sequence where
|
||||
/// a scalar belongs — draws **no stripe**, and the value stays on disk exactly as written.
|
||||
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
|
||||
/// content does — a title wrapping across its full four lines included.
|
||||
@ViewBuilder
|
||||
private var accentStripe: some View {
|
||||
if let color = Palette.color(for: card.background) {
|
||||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
|
||||
.fill(color)
|
||||
.frame(width: stripeWidth)
|
||||
// Decoration only: the whole plate is one click target for selection.
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection and rename plumbing
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||||
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
|
||||
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
|
||||
/// to one implementation (`BoardDrops`).
|
||||
private var isFileHovered: Bool {
|
||||
drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == 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 —
|
||||
@@ -1371,7 +954,7 @@ private struct NewCardStubView: View {
|
||||
/// 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 {
|
||||
struct InlineTitleField: View {
|
||||
|
||||
@Binding var text: String
|
||||
let prompt: String
|
||||
|
||||
Reference in New Issue
Block a user