The replica framed to a font-derived nominal width, so faces in wide lanes' interior masonry columns and narrow lanes dragged a preview of the wrong size and the cursor could sit over empty space beside it. CardFaceView's existing onGeometryChange now reports size, not height alone - height still feeds LaneDropRegistry, width feeds the replica - and BoardMetrics.cardReplicaWidth(measured:bodyPointSize:) keeps the old figure as the documented not-yet-laid-out fallback. Same row, same paddings, same lineLimit at the same width means the same height, so the preview is now an exact overlay of the face it left. Manual verification pending alongside the lane grab-point card: 3x vs 1x lanes, interior masonry columns, window re-divide, trash rows, large text sizes, drag-at-creation fallback. 3 tests. 1661 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
724 lines
39 KiB
Swift
724 lines
39 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - Which side of the board a face is on
|
|
|
|
/// **The one axis a card face has** — which container it is drawn in, and the collaborator that
|
|
/// container's grammar needs.
|
|
///
|
|
/// 03-board-ui.md § Trash, resettled 2026-07-28: "A trashed card is an ordinary card in a special
|
|
/// place — search, selection, rendering, styling, and clipboard all treat it exactly like any other
|
|
/// card". So there is **one** card face in this app, and this is the whole of what differs between
|
|
/// its two homes. Everything the two sides share — the plate, the stripe, the icon tint, the
|
|
/// attachments chip, the selection treatment, the cut dim, the marquee registration, the drag — is
|
|
/// shared by construction rather than by two views agreeing.
|
|
///
|
|
/// The three differences are all *absences on the trash side*, and each is 04-interactions.md ▸ The
|
|
/// trash's "everything edit-shaped is disabled on trash selections" showing up as a branch that is
|
|
/// simply not taken:
|
|
///
|
|
/// - **no Open** — no double-click gesture at all ("trash cards don't open — double-click stops at
|
|
/// selection"), which is also why `openCard` is the board case's payload rather than the view's;
|
|
/// - **no Rename** — the inline editor is board-only (`isRenaming`), so a rename that somehow
|
|
/// targeted a trashed card would render nothing rather than open a field over it;
|
|
/// - **no Style…** — no popover anchor, and no Style rows in the context menu.
|
|
///
|
|
/// Plus the two that are not about editing: Finder file drops are inert over the trash (▸ The trash),
|
|
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
|
|
/// it needs the window's confirmation host (11-command-nexus.md ▸ Context menus' Trash cards row).
|
|
enum CardFaceRole {
|
|
|
|
/// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
|
|
/// (04-interactions.md ▸ Selection).
|
|
case board(openCard: (ItemID) -> Void)
|
|
|
|
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
|
|
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
|
|
case trash(confirmations: TrashConfirmations)
|
|
|
|
/// Which container a click on this face selects in, which container its drag begins in, and which
|
|
/// container the rubber band sweeps it as — one answer, so the three can never disagree
|
|
/// (`SelectionGrammar`: "the container is the surface's, not the item's").
|
|
var container: ItemContainer {
|
|
switch self {
|
|
case .board: .board
|
|
case .trash: .trash
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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).
|
|
///
|
|
/// ### One face, two containers
|
|
///
|
|
/// **This is the trash's row too** (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized
|
|
/// trash): "a trashed card is an ordinary card in a special place … rendering … treat it exactly like
|
|
/// any other card". The tombstone era's compact dimmed plate is retired with the tombstones it drew;
|
|
/// a trashed card wears its style, its stripe, its icon tint and its attachments chip exactly as it
|
|
/// did in its lane, because it is the same card and the same view. What the trash takes away is
|
|
/// listed on `CardFaceRole` and nowhere else.
|
|
///
|
|
/// ### 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).
|
|
struct CardFaceView: View {
|
|
|
|
let store: BoardStore
|
|
let card: Card
|
|
|
|
/// Which side of the board this face is drawn on, and what that side's grammar needs — the view's
|
|
/// one axis (`CardFaceRole`).
|
|
let role: CardFaceRole
|
|
|
|
/// 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
|
|
|
|
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
|
@Environment(AppModel.self) private var appModel
|
|
|
|
/// Increase Contrast, for the plate's borders below (10-accessibility.md: "Increase Contrast
|
|
/// strengthens borders and the selection indicator"). Read from the environment and handed to
|
|
/// `Accommodations`, which owns what "increased" does to a stroke.
|
|
@Environment(\.colorSchemeContrast) private var contrast
|
|
|
|
/// The live body metric — every figure this face lays out on is a multiple of it
|
|
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than
|
|
/// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side.
|
|
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
|
|
|
/// 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 `BoardMetrics` 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 { BoardMetrics.cardCornerRadius(bodyPointSize: pointSize) }
|
|
|
|
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
|
/// treatment shootout).
|
|
private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) }
|
|
|
|
/// This face's drawn width — **the replica's** (`replicaFace`). Measured rather than derived,
|
|
/// because a face is as wide as the interior masonry column its lane gives it, and that is a
|
|
/// function of the lane's slot width and its column count rather than of the font
|
|
/// (`LaneLayoutMath`, `MasonryLayout`). Zero until the first layout, which is what the metric's
|
|
/// fallback is for.
|
|
@State private var measuredWidth: CGFloat = 0
|
|
|
|
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
|
|
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
|
|
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
|
|
/// gestures that fire and refuse (`CardFaceRole`).
|
|
@ViewBuilder
|
|
var body: some View {
|
|
switch role {
|
|
case let .board(openCard):
|
|
face
|
|
// "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)
|
|
})
|
|
.contextMenu { boardMenu(openCard: openCard) }
|
|
// **The menu's rows, additionally as custom actions** — "where SwiftUI additionally
|
|
// surfaces menu items as custom accessibility actions, that's free improvement, not
|
|
// a separate design surface" (10-accessibility.md ▸ Actions come from the context
|
|
// menu). The menu stays the inventory and stays reachable the standard way (VO-⇧-M).
|
|
// Style… is absent for `LaneView`'s reason: it opens a popover, and the quick-style
|
|
// swatch `Picker` beside it is not an action.
|
|
.accessibilityActions { boardActions(openCard: openCard) }
|
|
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
|
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
|
}
|
|
case let .trash(confirmations):
|
|
face
|
|
.contextMenu { trashMenu(confirmations: confirmations) }
|
|
// The trash's two rows and **no third** — "there is no Open"
|
|
// (10-accessibility.md ▸ Trash lane; 03-board-ui.md's no-editing-in-the-trash). The
|
|
// absence is structural on this side too: `openCard` is the board case's payload, so
|
|
// there is nothing here an Open action could even call.
|
|
.accessibilityActions { trashActions(confirmations: confirmations) }
|
|
}
|
|
}
|
|
|
|
/// Everything the two containers share — which, after the pivot, is the face itself.
|
|
private var face: some View {
|
|
titleRow
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
|
// 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.
|
|
//
|
|
// **Increase Contrast strengthens both the ring and the plate's edge** (10-accessibility.md):
|
|
// the stroke goes a point heavier, and an *unselected* card — which normally floats on its
|
|
// fill alone — gains a separator hairline, because "this is one card and that is another" is
|
|
// exactly the distinction the setting exists to rescue (`Accommodations`).
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
|
|
)
|
|
// 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. It
|
|
// genuinely fires on the trash side too: "⌘X works — cut in the trash, paste into a lane is
|
|
// the keyboard-native restore" (04 ▸ The trash, resettled 2026-07-28).
|
|
.cutTreatment(of: card.id, in: store)
|
|
// The face being dragged out dims the same way while the session is in flight on the trash
|
|
// side, where the source stays visible: a restore is not a removal until the write lands.
|
|
// (On the board a dragged card is lifted out of the resting layout entirely, so this never
|
|
// has anything to act on there — `LaneView.renderedCards`.)
|
|
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
|
.contentShape(Rectangle())
|
|
// **A card is one flattened accessibility element** (10-accessibility.md ▸ The board through
|
|
// VoiceOver): "label = title (or the untitled placeholder), value carries the attachment
|
|
// count when present, selected state via trait. Face icon and chips are decorative — folded
|
|
// into the element, never separately focusable". So the icon, the accent stripe and the
|
|
// paperclip contribute nothing of their own — the count they stood for rides the value below.
|
|
//
|
|
// `.contain` while a rename is open, `LaneView`'s header rule for its reason: flattening
|
|
// would swallow the text field the user is typing into. Board-only by construction, since
|
|
// `isRenaming` is (`CardFaceRole`).
|
|
.accessibilityElement(children: isRenaming ? .contain : .ignore)
|
|
.accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value))
|
|
// The attachment count, the deferred cut's "cut, pending paste", or both — and the empty
|
|
// string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why
|
|
// it is not a conditional modifier).
|
|
.accessibilityValue(AccessibilityPhrases.cardValue(
|
|
attachments: card.attachments.count,
|
|
isCutPending: store.transient.pendingCut.ids.contains(card.id)
|
|
))
|
|
// "Selection state is always readable from the element (trait)" — the other half of "state
|
|
// is never colour-alone", whose visible half is the accent stroke above.
|
|
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
|
// **VO-Space toggles this card's selection** — "moving the VoiceOver cursor never mutates
|
|
// selection. VO-Space on a card toggles its selection (the ⌘-click analogue — a toggle,
|
|
// never plain click's replace)". Routed through the same `BoardStore.click` funnel the
|
|
// pointer uses, with the ⌘ modifier, so the homogeneity rule and the container boundary are
|
|
// `SelectionGrammar`'s single answer rather than a second one written here.
|
|
.accessibilityAction { toggleSelection() }
|
|
// **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.
|
|
//
|
|
// **The container travels with the click**, and that is what keeps the one remaining
|
|
// homogeneity boundary true: a ⌘-click across it replaces rather than mixing
|
|
// (04-interactions.md ▸ The trash). A double click in the trash is two of these and nothing
|
|
// more — no editor, no card window, no timer.
|
|
.onTapGesture {
|
|
store.click(SelectionTarget(id: card.id, kind: .card, container: role.container), modifier: .current)
|
|
}
|
|
// **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(startDrag, preview: { dragReplica })
|
|
// The card's drawn size. Its **height** goes to 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`). The trash side registers too: a trash card
|
|
// dragged out is an ordinary card session, and the shadow it opens in the destination lane
|
|
// should be its real footprint rather than the nominal guess.
|
|
//
|
|
// Its **width** stays here, for the drag replica: a face is as wide as its lane's interior
|
|
// column, which no metric can derive and only the laid-out face can report
|
|
// (`BoardMetrics.cardReplicaWidth(measured:bodyPointSize:)`).
|
|
.onGeometryChange(for: CGSize.self) { $0.size } action: { size in
|
|
drops.registry.update(height: size.height, for: card.id)
|
|
measuredWidth = size.width
|
|
}
|
|
.onDisappear { drops.registry.removeHeight(card.id) }
|
|
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
|
|
}
|
|
|
|
// MARK: - The card drag
|
|
|
|
/// Begins this card's system drag session — in **its own container**, which is the whole of what
|
|
/// makes a trash card's drag a restore (04-interactions.md ▸ The trash; `DragLocality.operation`).
|
|
private func startDrag() -> NSItemProvider {
|
|
switch role.container {
|
|
case .board: startBoardCardDrag()
|
|
case .trash: startTrashCardDrag()
|
|
}
|
|
}
|
|
|
|
/// A lane card's drag (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 startBoardCardDrag() -> NSItemProvider {
|
|
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
|
let snapshot = store.snapshot
|
|
let ids = draggedIDs
|
|
|
|
// 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()
|
|
}
|
|
|
|
/// A trash card's drag out — **the restore**, and deliberately not special: an ordinary `.cards`
|
|
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same
|
|
/// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
|
|
/// out … there is no restore-specific machinery and no Put Back" (03 § Trash).
|
|
///
|
|
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
|
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
|
|
/// every other card drop uses, committed by the same `moveCards`.
|
|
///
|
|
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
|
|
/// rows are drawn in, which is `order` ascending like any lane's.
|
|
private func startTrashCardDrag() -> NSItemProvider {
|
|
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
|
let ids = draggedIDs
|
|
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
|
guard !rows.isEmpty else { return NSItemProvider() }
|
|
|
|
let root = store.rootURL
|
|
let payload = DragPayload(
|
|
boardRoot: root,
|
|
kind: .cards,
|
|
container: .trash,
|
|
items: rows.map {
|
|
DragPayload.Item(
|
|
id: $0.id.rawValue,
|
|
folder: ItemPath.trashCard($0.id).folder(under: root).path,
|
|
title: $0.title.value
|
|
)
|
|
}
|
|
)
|
|
drops.session.beginCards(
|
|
rows.map(\.id),
|
|
folders: payload.folders,
|
|
heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
|
|
container: .trash,
|
|
source: store
|
|
)
|
|
return payload.itemProvider()
|
|
}
|
|
|
|
/// What travels: the whole selection when this card is in it, else this card alone — the drag's
|
|
/// half of the context-menu targeting rule, and container-scoped like everything else.
|
|
private var draggedIDs: Set<ItemID> {
|
|
let selection = store.selection
|
|
guard selection.container == role.container,
|
|
selection.ids.contains(card.id),
|
|
selection.ids.count > 1
|
|
else { return [card.id] }
|
|
return selection.ids
|
|
}
|
|
|
|
/// 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 == role.container && 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(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
|
}
|
|
|
|
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
|
/// editor and no geometry observers, and crucially no marquee registration (one built out of the
|
|
/// live face would re-register the card's frame from inside the preview's geometry and then
|
|
/// deregister it when the image went away, quietly stealing the card from the rubber band and the
|
|
/// arrow keys).
|
|
private var replicaFace: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
|
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(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
|
.padding(.leading, stripeWidth)
|
|
// **The size of the face it was lifted from**, taken from that face's own measurement rather
|
|
// than from a representative figure: a card is as wide as its lane's interior column, so a
|
|
// replica drawn at a nominal width is visibly a different card from the one under the cursor,
|
|
// and — since the system centres a preview on the view the drag started from — leaves the
|
|
// pointer sitting beside the image instead of on it. The width is the only frame this needs:
|
|
// the replica lays the same row out with the same paddings and the same `lineLimit`, so at
|
|
// the face's width it comes out at the face's height (`BoardMetrics`).
|
|
.frame(
|
|
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
|
|
alignment: .leading
|
|
)
|
|
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
|
.overlay(alignment: .leading) { accentStripe }
|
|
}
|
|
|
|
// MARK: - Context menus
|
|
|
|
/// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
|
|
/// menus' Card row, in its order.
|
|
@ViewBuilder
|
|
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> 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") { beginRename() }
|
|
.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") { deleteTargets() }
|
|
.disabled(!store.acceptsBoardMutations)
|
|
}
|
|
|
|
/// `boardMenu`'s plain rows as VoiceOver custom actions — every one calling the *same* private
|
|
/// method its menu row does, so the two surfaces cannot come to mean different things.
|
|
@ViewBuilder
|
|
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
|
|
Button("Open") { openCard(card.id) }
|
|
Button("Rename") { beginRename() }
|
|
.disabled(!store.acceptsBoardMutations)
|
|
Button("Delete") { deleteTargets() }
|
|
.disabled(!store.acceptsBoardMutations)
|
|
}
|
|
|
|
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
|
|
/// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder").
|
|
///
|
|
/// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash).
|
|
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
|
/// enabled on trash selections", read-only lock included — inspecting a folder before a purge is
|
|
/// exactly the errand it exists for.
|
|
///
|
|
/// The Delete row goes through the window's confirmation host rather than straight to the store,
|
|
/// because this delete is the **permanent** one and the alert is what stands between it and an
|
|
/// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`).
|
|
@ViewBuilder
|
|
private func trashMenu(confirmations: TrashConfirmations) -> some View {
|
|
Button("Delete") { requestPurge(confirmations) }
|
|
.disabled(!store.acceptsBoardMutations)
|
|
|
|
Divider()
|
|
|
|
Button("Reveal in Finder") { revealInFinder() }
|
|
}
|
|
|
|
/// `trashMenu`'s rows as VoiceOver custom actions — `boardActions`' twin, two rows and no Open.
|
|
@ViewBuilder
|
|
private func trashActions(confirmations: TrashConfirmations) -> some View {
|
|
Button("Delete") { requestPurge(confirmations) }
|
|
.disabled(!store.acceptsBoardMutations)
|
|
Button("Reveal in Finder") { revealInFinder() }
|
|
}
|
|
|
|
// MARK: - The rows' bodies
|
|
|
|
/// Board ▸ Rename's store path, seeded with the card's live title.
|
|
private func beginRename() {
|
|
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
|
|
}
|
|
|
|
/// File ▸ Delete's store path over the context-menu target set.
|
|
private func deleteTargets() {
|
|
store.delete(targetIDs)
|
|
}
|
|
|
|
/// The trash's **permanent** delete, through the window's confirmation host — never straight to
|
|
/// the store, because the alert is what stands between this row and an unrecoverable loss.
|
|
private func requestPurge(_ confirmations: TrashConfirmations) {
|
|
confirmations.requestTrashDelete(of: targetIDs, in: store)
|
|
}
|
|
|
|
private func revealInFinder() {
|
|
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
|
}
|
|
|
|
/// VO-Space's landing: the ⌘-click funnel, on this card, **in this face's container** — so a
|
|
/// trash card's toggle can no more mix with a board selection than a ⌘-click could.
|
|
private func toggleSelection() {
|
|
store.click(
|
|
SelectionTarget(id: card.id, kind: .card, container: role.container),
|
|
modifier: .command
|
|
)
|
|
}
|
|
|
|
/// 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 {
|
|
.items(targetIDs)
|
|
}
|
|
|
|
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
|
/// because `store.delete(_:)` takes one directly. Container-scoped, so a trash row's menu never
|
|
/// widens to a board selection and vice versa.
|
|
private var targetIDs: Set<ItemID> {
|
|
guard store.selection.container == role.container, store.selection.ids.contains(card.id) else {
|
|
return [card.id]
|
|
}
|
|
return store.selection.ids
|
|
}
|
|
|
|
/// The folders Reveal in Finder points at — resolved in this face's container, so a trash row
|
|
/// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
|
|
private var targetFolders: [URL] {
|
|
ItemPath.resolve(targetIDs, in: role.container, snapshot: store.snapshot)
|
|
.map { $0.folder(under: store.rootURL) }
|
|
}
|
|
|
|
// MARK: - Title row
|
|
|
|
private var titleRow: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
|
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. 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()
|
|
if case let .board(openCard) = role { 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 *value* rather than onto the face: it is useful to know, not to look at.
|
|
///
|
|
/// **Decorative, and hidden outright** (10-accessibility.md): "face icon and chips are
|
|
/// decorative — folded into the element, never separately focusable … the flattened element
|
|
/// carries the attachment count in its value". The flattening above would drop a label here
|
|
/// anyway; saying it explicitly is what keeps the chip inert in the replica too, which is drawn
|
|
/// outside the flattened face.
|
|
@ViewBuilder
|
|
private var attachmentsIndicator: some View {
|
|
if !card.attachments.isEmpty {
|
|
Image(systemName: "paperclip")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
/// 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 == role.container && store.selection.ids.contains(card.id)
|
|
}
|
|
|
|
/// What the plate's edge is painted with: the accent when this card is selected or a Finder file
|
|
/// drag is hovering it, a separator hairline under Increase Contrast, and nothing otherwise.
|
|
///
|
|
/// The three-way branch is the whole of "state is never colour-alone, and Increase Contrast
|
|
/// strengthens borders" meeting on one shape: the resting border is *chrome* (every card gets
|
|
/// one, so it says nothing), and the accent ring stays the only thing that means "this one".
|
|
private var plateStroke: AnyShapeStyle {
|
|
if isSelected || isFileHovered {
|
|
AnyShapeStyle(Color.accentColor)
|
|
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
|
|
AnyShapeStyle(.separator)
|
|
} else {
|
|
AnyShapeStyle(.clear)
|
|
}
|
|
}
|
|
|
|
private var plateStrokeWidth: CGFloat {
|
|
guard isSelected || isFileHovered else {
|
|
return Accommodations.borderWidth(1, contrast: contrast)
|
|
}
|
|
return Accommodations.borderWidth(isFileHovered ? 2.5 : 1.5, contrast: contrast)
|
|
}
|
|
|
|
/// 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`).
|
|
///
|
|
/// **Never on the trash side**: "Finder file drops on trash cards are inert" (04-interactions.md
|
|
/// ▸ The trash), and the trash column's own delegate clears the file highlight rather than
|
|
/// proposing one — so this is a second, structural statement of the same rule.
|
|
private var isFileHovered: Bool {
|
|
role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
|
|
}
|
|
|
|
/// **Board-only** — "everything edit-shaped is disabled on trash selections … Rename"
|
|
/// (04-interactions.md ▸ The trash). No path opens a rename on a trashed card, and this makes a
|
|
/// stray one render nothing rather than putting a live field over a card that cannot be edited.
|
|
private var isRenaming: Bool {
|
|
role.container == .board && store.transient.renameEditor?.targetID == card.id
|
|
}
|
|
|
|
private var draft: Binding<String> {
|
|
Binding(
|
|
get: { store.transient.renameEditor?.draftTitle ?? "" },
|
|
set: { store.transient.updateRenameDraft($0) }
|
|
)
|
|
}
|
|
}
|