The lane title bar becomes real: leading SF Symbol (hand-written names render leniently, unknown ones fall back to the level default), title or secondary untitled placeholder, a quiet count badge that counts exactly the cards the body renders (so the m5 search filter is followed by construction), and a new-card button. The whole bar is the reorder drag surface — no grip — with click-vs-movement splitting select from drag; a pure proposal function maps the drag to an insertion index and release commits through the Writer's same-parent degenerate reorder, compacting and retrying when midpoint precision runs out. Clicking never edits: inline rename is Return on the sole selected card or Board > Rename for either kind, a third transient editor beside the placeholder that tracks its target by UUID, commits on focus loss, discards silently when the target vanishes, and removes the title key on an empty commit. The new-card placeholder renders at last — the settled Cmd-N target rule (pure, tested) files it after the anchor card, at a selected lane's bottom, or into the last-active lane; Return commits and re-selects the lane, Cmd-Return also opens the card window, and a failed create discards the overlay. New Card / New Lane / Rename land in the menus with focused-editor and read-only validation; rename gets its own WriteOperation case in the banner vocabulary. 59 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
550 lines
25 KiB
Swift
550 lines
25 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - The strip's half of the header drag
|
||
|
||
/// What the strip lends a lane so its **whole title bar** can be the drag surface (03-board-ui.md §
|
||
/// Lane, "no separate grip").
|
||
///
|
||
/// The gesture lives on the header — that is where the design puts it — but the two things it needs
|
||
/// are the strip's: where this lane currently rests (the origin the translation is measured from)
|
||
/// and what a release means (a proposal computed against the live lane order, then a write). Both
|
||
/// arrive as closures rather than as values because both must be read at *gesture* time, not at
|
||
/// body-evaluation time.
|
||
@MainActor
|
||
struct LaneHeaderDrag {
|
||
/// This lane's resting centre in strip coordinates, read the instant the drag begins and frozen
|
||
/// for its duration (`LaneReorderSession.startCentre`).
|
||
let startCentre: () -> CGFloat
|
||
/// Commit the reorder at whatever the current proposal is, and end the session.
|
||
let commit: () -> Void
|
||
}
|
||
|
||
// MARK: - LaneView
|
||
|
||
/// One lane: a title bar and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
|
||
///
|
||
/// ### The title bar (this milestone's subject)
|
||
///
|
||
/// Leading SF Symbol from `icon` — lenient, an unknown name renders the `square.stack` default
|
||
/// (`ItemSymbol`) — then the title or its quiet "Untitled" placeholder, a quiet secondary
|
||
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
|
||
/// a plain click selects the lane, movement past a small threshold begins a reorder
|
||
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
|
||
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
|
||
///
|
||
/// ### What is still a later card's
|
||
///
|
||
/// The lane context menu (Rename, Style…, the quick-style recents row, the Width stepper, Delete),
|
||
/// the colour accent band, and the search-aware filtering behind the count all belong to later
|
||
/// milestones. The **card face** is likewise still a stub — `CardStubView` gains the leading icon,
|
||
/// the attachment chip, the cut treatment and the attachment carousel with the card-face card; what
|
||
/// it grows here is only what inline rename and click selection require.
|
||
struct LaneView: View {
|
||
|
||
let store: BoardStore
|
||
let lane: Lane
|
||
|
||
/// Interior masonry columns — the lane's width units, or the resize session's snapped count
|
||
/// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can
|
||
/// override it (see `BoardView.laneSlot`).
|
||
let columns: Int
|
||
|
||
/// The strip's reorder session, so the header knows whether *it* is the lane in flight.
|
||
let reorder: LaneReorderSession
|
||
|
||
let headerDrag: LaneHeaderDrag
|
||
|
||
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens
|
||
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
|
||
/// business knowing about `WindowGroup` keys.
|
||
let openCard: (ItemID) -> Void
|
||
|
||
/// Spacing between cards, and between the interior columns.
|
||
private let cardSpacing: CGFloat = 8
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
header
|
||
cardStack
|
||
}
|
||
.padding(6)
|
||
.background(selectionBackground)
|
||
.overlay(selectionStroke)
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var header: some View {
|
||
headerContent
|
||
// The bar is the drag surface, so it must be hit-testable across its whole width —
|
||
// including the empty stretch between the badge and the button.
|
||
.contentShape(Rectangle())
|
||
.gesture(headerGesture)
|
||
.overlay(alignment: .trailing) { newCardButton }
|
||
}
|
||
|
||
private var headerContent: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||
.foregroundStyle(.secondary)
|
||
.imageScale(.medium)
|
||
headerTitle
|
||
countBadge
|
||
Spacer(minLength: 0)
|
||
}
|
||
// Reserves the button's width so a long title truncates before it collides, and keeps the
|
||
// button out of the gestured region.
|
||
.padding(.trailing, 22)
|
||
.padding(.horizontal, 4)
|
||
}
|
||
|
||
/// The title, or the rename editor when this lane is the one being renamed.
|
||
///
|
||
/// A lane's **only** rename path is Board ▸ Rename (04-interactions.md ▸ Selection: "the menu
|
||
/// item is a lane's only rename path, since Return on a lane creates a card"), so nothing in
|
||
/// this view opens the editor — it only renders one that is already open.
|
||
@ViewBuilder
|
||
private var headerTitle: some View {
|
||
if isRenaming {
|
||
InlineTitleField(
|
||
text: renameDraft,
|
||
prompt: "Lane name",
|
||
onCommit: { store.commitRename() },
|
||
onAbandon: { store.transient.discardRename() },
|
||
onFocusLoss: { store.commitRename() },
|
||
// A lane has no card window; ⌘↩ still commits, which is the half of the rule that
|
||
// applies (04 ▸ Grammar's carve-out is "commits the edit — placeholder or rename —
|
||
// and open[s] the card window", and only a card has one to open).
|
||
onCommitAndOpen: { store.commitRename() }
|
||
)
|
||
.font(.headline)
|
||
} else {
|
||
Text(lane.title.value ?? "Untitled")
|
||
.font(.headline)
|
||
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
|
||
/// The card-count badge — quiet, secondary (03-board-ui.md § Lane).
|
||
///
|
||
/// **It counts exactly what the body renders**, because it reads the same `renderedCards` the
|
||
/// masonry iterates. That is deliberate rather than incidental: "The count reads the search
|
||
/// filter like every other surface — during a search it shows the visible count, not the
|
||
/// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge
|
||
/// follows by construction, with no second rule to keep in step.
|
||
private var countBadge: some View {
|
||
Text("\(renderedCards.count)")
|
||
.font(.caption)
|
||
.monospacedDigit()
|
||
.foregroundStyle(.secondary)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 1)
|
||
.background(Capsule().fill(.quaternary))
|
||
}
|
||
|
||
/// The new-card button — a **pointer twin** of File ▸ New Card whose click *names its target*:
|
||
/// "the lane header's new-card button overrides [the ⌘N target] rule — the click names its
|
||
/// target lane, selection notwithstanding" (11-command-nexus.md ▸ Pointer grammar, settled), so
|
||
/// it passes this lane and no anchor rather than consulting `NewCardTarget`.
|
||
private var newCardButton: some View {
|
||
Button {
|
||
store.transient.beginPlaceholder(inLane: lane.id)
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
.imageScale(.small)
|
||
.foregroundStyle(.secondary)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("New card in \(lane.title.value ?? "Untitled")")
|
||
// Mutating, so the read-only lock disables it like every other write path
|
||
// (02-architecture.md § The lock's scope), and the focused-editor rule closes it while an
|
||
// inline editor is open (04 ▸ Grammar) — the pointer twin of a disabled menu item.
|
||
.disabled(store.isReadOnly || store.isEditingInline)
|
||
}
|
||
|
||
/// One gesture recognising both halves of 04-interactions.md ▸ Selection's click-vs-drag split:
|
||
/// "a plain click on the title bar selects the lane; the drag surface engages only on movement".
|
||
///
|
||
/// `minimumDistance: 0` so the release is seen even when nothing moved — that release *is* the
|
||
/// click. `.global` coordinates because the strip's own space shifts as siblings reflow under
|
||
/// the proposal, and a translation measured against a moving frame is not a pointer delta.
|
||
private var headerGesture: some Gesture {
|
||
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
||
.onChanged { value in
|
||
// Selection stays live under the lock; reordering does not (02 § The lock's scope).
|
||
// The focused-editor rule holds a drag off too: a reorder is a board command.
|
||
guard !store.isReadOnly, !store.isEditingInline else { return }
|
||
if !reorder.isDragging(lane.id) {
|
||
guard abs(value.translation.width) > LaneReorderSession.threshold else { return }
|
||
reorder.begin(laneID: lane.id, startCentre: headerDrag.startCentre())
|
||
}
|
||
reorder.update(translation: value.translation.width)
|
||
}
|
||
.onEnded { _ in
|
||
if reorder.isDragging(lane.id) {
|
||
headerDrag.commit()
|
||
} else {
|
||
// A plain click on the header always selects — unlike lane empty space, it does
|
||
// not toggle off. 04 gives the click-again-to-unselect behaviour to empty space
|
||
// only, and a full lane has no empty space to reach for.
|
||
store.select([lane.id], liveness: .live)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Body
|
||
|
||
/// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one
|
||
/// click selects the lane or, when it is already the selection, clears it; a double click
|
||
/// creates a card at the bottom with its title editor focused.
|
||
private var cardStack: some View {
|
||
ScrollView(.vertical) {
|
||
// Cards stay standard width whatever the lane spans: at a slot width of
|
||
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
|
||
// `units` columns of `standard` (03-board-ui.md § Layout — full visibility).
|
||
MasonryLayout(columns: columns, spacing: cardSpacing) {
|
||
ForEach(slots) { slot in
|
||
switch slot {
|
||
case let .card(card):
|
||
CardStubView(store: store, card: card, openCard: openCard)
|
||
case .placeholder:
|
||
NewCardStubView(store: store, openCard: openCard)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.contentShape(Rectangle())
|
||
// Order matters: the two-tap recogniser must be attached first so a double click is not
|
||
// consumed as two singles.
|
||
.onTapGesture(count: 2) {
|
||
guard !store.isReadOnly, !store.isEditingInline else { return }
|
||
store.transient.beginPlaceholder(inLane: lane.id)
|
||
}
|
||
.onTapGesture { toggleLaneSelection() }
|
||
}
|
||
}
|
||
|
||
/// What the masonry lays out: the rendered cards, plus the new-card placeholder when this lane
|
||
/// is the one being created into.
|
||
///
|
||
/// The overlay is inserted **at the position the card will actually take** — after its anchor
|
||
/// for ⌘N's "immediately after it", at the bottom otherwise — by asking the very function the
|
||
/// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card
|
||
/// cannot appear anywhere but where the real card lands.
|
||
private var slots: [LaneSlot] {
|
||
var result = renderedCards.map(LaneSlot.card)
|
||
guard let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id else {
|
||
return result
|
||
}
|
||
let position = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
||
result.insert(.placeholder, at: position ?? result.count)
|
||
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.
|
||
///
|
||
/// This is also the collection m5's search filter narrows, which is what keeps the count badge
|
||
/// honest for free — see `countBadge`.
|
||
private var renderedCards: [Card] {
|
||
lane.cards.filter { !$0.isDeleted }
|
||
}
|
||
|
||
// MARK: - Selection
|
||
|
||
private var isSelected: Bool {
|
||
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
|
||
}
|
||
|
||
/// Click on empty space: select, or clear when this lane is already *the* selection.
|
||
///
|
||
/// "Single click selects the lane (click again to unselect)". The toggle-off tests for a
|
||
/// sole-membership selection rather than mere containment, so a future ⌘-click multi-selection
|
||
/// of lanes is narrowed by a click rather than wiped by it — the modifier grammar itself
|
||
/// (⌘-click toggles, ⇧-click range-extends, rubber band, homogeneity enforcement) is **m5's
|
||
/// selection-model card**, and nothing here should pre-empt it.
|
||
private func toggleLaneSelection() {
|
||
if store.selection.liveness == .live, store.selection.ids == [lane.id] {
|
||
store.clearSelection()
|
||
} else {
|
||
store.select([lane.id], liveness: .live)
|
||
}
|
||
}
|
||
|
||
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet —
|
||
/// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as
|
||
/// a fill that would compete with it once that lands.
|
||
private var selectionBackground: some View {
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear))
|
||
}
|
||
|
||
private var selectionStroke: some View {
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||
}
|
||
|
||
// MARK: - Rename plumbing
|
||
|
||
private var isRenaming: Bool {
|
||
store.transient.renameEditor?.targetID == lane.id
|
||
}
|
||
|
||
/// The draft, as a binding onto transient state rather than as `@State`: the editor's text lives
|
||
/// in `TransientBoardState` because a reload has rules about it (the vanish discard), and a
|
||
/// second copy in the view would be the one the commit did not read.
|
||
private var renameDraft: Binding<String> {
|
||
Binding(
|
||
get: { store.transient.renameEditor?.draftTitle ?? "" },
|
||
set: { store.transient.updateRenameDraft($0) }
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Lane slots
|
||
|
||
/// What a lane's masonry lays out — its cards, plus at most one pseudo-card.
|
||
///
|
||
/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its
|
||
/// title commits (02-architecture.md § Layering, the one named exception to the one-way flow).
|
||
/// Modelling it as a sibling case rather than as a fake `Card` is what keeps that true — nothing can
|
||
/// accidentally hand it to code expecting an item that exists.
|
||
private enum LaneSlot: Identifiable {
|
||
case card(Card)
|
||
case placeholder
|
||
|
||
var id: String {
|
||
switch self {
|
||
case let .card(card): "card:\(card.id.rawValue)"
|
||
// Constant, because there is only ever one placeholder in one lane at a time and it must
|
||
// keep its identity — and therefore its keyboard focus — while the user types.
|
||
case .placeholder: "placeholder"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Card stub
|
||
|
||
/// A card, as a rounded plate with its title — **still a stand-in**, replaced by the card-face card,
|
||
/// which brings the leading icon, the attachment chip, the cut treatment and the sole-selected
|
||
/// card's attachment carousel (03-board-ui.md § Card face).
|
||
///
|
||
/// What it has grown here is only what this milestone owes: click-to-select with a selection
|
||
/// treatment, and the inline rename editor swapping in for the title when this card is the rename
|
||
/// target.
|
||
private struct CardStubView: View {
|
||
|
||
let store: BoardStore
|
||
let card: Card
|
||
let openCard: (ItemID) -> Void
|
||
|
||
var body: some View {
|
||
Group {
|
||
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)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(10)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||
)
|
||
.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.
|
||
.onTapGesture { store.select([card.id], liveness: .live) }
|
||
}
|
||
|
||
private var isSelected: Bool {
|
||
store.selection.liveness == .live && store.selection.ids.contains(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 —
|
||
/// 02-architecture.md § Layering's one named exception to the one-way flow, finally rendered.
|
||
///
|
||
/// Two faces, one per phase:
|
||
///
|
||
/// - **`.editing`** — a focused text field. Return commits, Escape abandons, and **click-away
|
||
/// discards**: the placeholder's rule, "the deliberate exception because nothing exists on disk
|
||
/// yet" (04-interactions.md ▸ Grammar).
|
||
/// - **`.awaitingArrival`** — the committed title as plain text, deliberately *not* an editor. The
|
||
/// Writer's create has run and the overlay is only covering the gap until the watcher round-trips
|
||
/// the real card; leaving a live field there would invite edits that have nowhere to go, and its
|
||
/// focus loss would fire the discard rule against a card that is already on its way.
|
||
private struct NewCardStubView: View {
|
||
|
||
let store: BoardStore
|
||
let openCard: (ItemID) -> Void
|
||
|
||
var body: some View {
|
||
Group {
|
||
if isEditing {
|
||
InlineTitleField(
|
||
text: draft,
|
||
prompt: "Card title",
|
||
onCommit: { commit() },
|
||
onAbandon: { store.transient.discardPlaceholder() },
|
||
onFocusLoss: { store.transient.discardPlaceholder() },
|
||
onCommitAndOpen: {
|
||
// The one board command that stays enabled mid-edit: commit, then open
|
||
// (04 ▸ Grammar's carve-out). A commit that discarded — empty title, a
|
||
// vanished lane, a failed create — hands back no id and opens nothing.
|
||
if let id = commit() { openCard(id) }
|
||
}
|
||
)
|
||
.font(.body)
|
||
} else {
|
||
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(4)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(10)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
|
||
)
|
||
}
|
||
|
||
private var isEditing: Bool {
|
||
store.transient.newCardPlaceholder?.phase == .editing
|
||
}
|
||
|
||
/// Commits, then **re-selects the lane** — "Return commits and re-selects the lane (next Return
|
||
/// = next card)" (04-interactions.md ▸ Grammar). The lane rather than the new card is what makes
|
||
/// a run of Return-type-Return file a stack of cards without the user's hands leaving the
|
||
/// keyboard.
|
||
///
|
||
/// The lane is read before the commit, because every discard path clears the overlay that holds
|
||
/// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting
|
||
/// something that renders nowhere would break the homogeneous-by-liveness invariant until the
|
||
/// next reload swept it away.
|
||
@discardableResult
|
||
private func commit() -> ItemID? {
|
||
let lane = store.transient.newCardPlaceholder?.laneID
|
||
let id = store.commitPlaceholder()
|
||
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
|
||
store.select([lane], liveness: .live)
|
||
}
|
||
return id
|
||
}
|
||
|
||
private var draft: Binding<String> {
|
||
Binding(
|
||
get: { store.transient.newCardPlaceholder?.draftTitle ?? "" },
|
||
set: { store.transient.updateDraft($0) }
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - The inline title field
|
||
|
||
/// The one text field all three inline editors wear — the new-card placeholder, a card rename, and
|
||
/// a lane rename — so the grammar around it is written once (04-interactions.md ▸ Grammar).
|
||
///
|
||
/// The four exits, and who differs on them:
|
||
///
|
||
/// | Exit | Placeholder | Rename |
|
||
/// |---|---|---|
|
||
/// | Return | commits | commits |
|
||
/// | Escape | discards | abandons |
|
||
/// | Click-away | **discards** | **commits** |
|
||
/// | ⌘↩ | commits + opens | commits + opens |
|
||
///
|
||
/// Only the click-away row differs, which is why it is a caller-supplied closure rather than a
|
||
/// branch in here: this view knows *that* focus left, never what that should mean.
|
||
///
|
||
/// **Every handler must be idempotent**, because the exits overlap by construction: Return commits
|
||
/// 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 {
|
||
|
||
@Binding var text: String
|
||
let prompt: String
|
||
let onCommit: () -> Void
|
||
let onAbandon: () -> Void
|
||
let onFocusLoss: () -> Void
|
||
let onCommitAndOpen: () -> Void
|
||
|
||
@FocusState private var isFocused: Bool
|
||
|
||
var body: some View {
|
||
TextField(prompt, text: $text)
|
||
.textFieldStyle(.plain)
|
||
.lineLimit(1)
|
||
.focused($isFocused)
|
||
// The editor is born focused: every entry point to it is a deliberate "edit this now"
|
||
// (Return, ⌘N, the header button, a double click, Board ▸ Rename), and one that landed
|
||
// unfocused would need a second click to do anything.
|
||
.onAppear { isFocused = true }
|
||
.onSubmit(onCommit)
|
||
// ⌘↩ before the field sees the Return: the one board command enabled mid-edit
|
||
// (04 ▸ Grammar). Anything without the modifier is passed straight through, so plain
|
||
// Return still reaches `onSubmit`.
|
||
.onKeyPress(keys: [.return], phases: .down) { press in
|
||
guard press.modifiers.contains(.command) else { return .ignored }
|
||
onCommitAndOpen()
|
||
return .handled
|
||
}
|
||
// Escape reaches a focused text field as AppKit's cancel operation on some paths and as
|
||
// a plain key press on others; both are wired to the same idempotent abandon rather than
|
||
// guessing which one this control will get.
|
||
.onKeyPress(.escape) {
|
||
onAbandon()
|
||
return .handled
|
||
}
|
||
.onExitCommand(perform: onAbandon)
|
||
.onChange(of: isFocused) { _, focused in
|
||
guard !focused else { return }
|
||
onFocusLoss()
|
||
}
|
||
}
|
||
}
|