One style-editor component, anchor-agnostic: a background grid (None well plus the 12 palette colors) and a curated symbol grid (the pathfinder's five-dozen set, leading well removing the icon key for the level default), selection-aware across cards, lanes, and the board itself. Batch edits compute per-dimension state — uniform, mixed (no well selected), or an off-palette value labeled verbatim outside the grids — and choosing a well applies to the whole target set as one write bracket, skipping no-ops per field. The popover tracks its target set live per the freshly ratified rule: targets re-resolve by UUID on every reload, a vanished target leaves the set, an emptied set dismisses the editor, and nothing ever silently retargets to the board. Anchors landing now: Board > Style (Opt-Cmd-S) and the card/lane context menus, which also carry the quick-style recents row (app-wide, persisted, capped at six, None never recorded) and the lane's width control twinning the menu chords. The styling system's other two renders arrive with it: a lane's background paints the C7 top-edge band, the board's paints the window content background — malformed values paint nothing and stay byte-identical on disk. 31 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
357 lines
18 KiB
Swift
357 lines
18 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// The lane strip — the board itself (03-board-ui.md § Layout — full visibility).
|
|
///
|
|
/// **Every lane is always on screen.** There is no horizontal scroll and no minimum lane width: the
|
|
/// window's width divides across the lanes' width units, a lane of n units taking n whole units of
|
|
/// that division, and resizing the window is the width control. A board with more units than the
|
|
/// window comfortably fits compresses every lane; that degenerate case is accepted, not floored
|
|
/// (the remedy is the user's — fewer units or a bigger window).
|
|
///
|
|
/// Two mechanisms change a lane's width and they are deliberately opposites (03-board-ui.md § Lane):
|
|
/// the **right-edge drag** grows or shrinks the *window* one standard width per snap so the other
|
|
/// lanes keep their exact pixels (`LaneResizeSession`), while the **stepper** — and its ⌥⌘→/⌥⌘←
|
|
/// keyboard face — re-divides the existing window width across the new unit total, compressing the
|
|
/// siblings and never touching the window (`BoardStore.setLaneWidth`).
|
|
///
|
|
/// ### The three interactions it hosts
|
|
///
|
|
/// - **Lane resize** — the right-edge grab strip (above).
|
|
/// - **Lane reorder** — the whole title bar is the drag surface (`LaneReorderSession`,
|
|
/// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be
|
|
/// order.
|
|
/// - **The keyboard's narrow slice** — Return's create/rename dispatch and Escape's step outward.
|
|
///
|
|
/// ### What is deliberately not here yet
|
|
///
|
|
/// The trash quasi-lane, the toolbar, search, styling, the lane context menu, and drag & drop's real
|
|
/// machinery (multi-drag, cross-board locality, the shadow's hold rule) all belong to later
|
|
/// milestone cards, and the card face inside `LaneView` is still a stub those cards replace. The
|
|
/// **selection grammar** here is likewise minimal — a click replaces the selection, and that is all:
|
|
/// ⌘-click toggling, ⇧-click ranges, the rubber band and the cards-XOR-lanes homogeneity rule are
|
|
/// m5's selection-model card.
|
|
struct BoardView: View {
|
|
|
|
let store: BoardStore
|
|
|
|
/// How the resize session reaches the host window it grows and shrinks. Injected by
|
|
/// `BoardWindowHost`, which owns the window controller; a closure because the window attaches
|
|
/// after the first body evaluation.
|
|
let window: @MainActor () -> NSWindow?
|
|
|
|
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from
|
|
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
|
|
/// needs the board's own window ref, which is the host's identity and not the board's.
|
|
let openCard: (ItemID) -> Void
|
|
|
|
/// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md §
|
|
/// Styling ▸ Controls).
|
|
@Environment(AppModel.self) private var appModel
|
|
|
|
/// One resize at a time, per window. `@State` so it lives exactly as long as this board window's
|
|
/// view does, which is the interaction's whole lifetime.
|
|
@State private var resize = LaneResizeSession()
|
|
|
|
/// One reorder at a time, per window — same lifetime, same reasoning.
|
|
@State private var reorder = LaneReorderSession()
|
|
|
|
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
|
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
|
/// must go back to meaning create/rename rather than nothing at all.
|
|
@FocusState private var isBoardFocused: Bool
|
|
|
|
/// The inter-lane gap, and the strip's outer margin — one number, because the standard-width
|
|
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
|
|
private let spacing: CGFloat = 12
|
|
|
|
var body: some View {
|
|
GeometryReader { viewport in
|
|
let lanes = liveLanes
|
|
// During a resize session the standard is FROZEN at its drag-start value: the window is
|
|
// animating mid-resize, so deriving the standard from the live viewport width would feed
|
|
// that animation back into every lane and pulse the whole strip. The window is sized on
|
|
// each tick so this frozen value equals what the viewport formula yields once the
|
|
// session ends — the handoff is seamless (see `LaneResizeSession`).
|
|
let standard = resize.isActive
|
|
? resize.standard
|
|
: LaneLayoutMath.standardWidth(
|
|
stripWidth: viewport.size.width,
|
|
totalUnits: LaneLayoutMath.totalUnits(of: lanes),
|
|
gap: spacing)
|
|
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
|
|
// the drag's would-be order while a reorder is in flight — which is how the siblings
|
|
// reflow to make room (04-interactions.md ▸ Drag and drop). The proposal is recomputed
|
|
// here on every render, so a foreign reload mid-drag simply moves the zones (rule 1 of
|
|
// that section's re-grounding trio).
|
|
let shown = shownLanes(lanes, standard: standard)
|
|
HStack(alignment: .top, spacing: spacing) {
|
|
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
|
|
laneSlot(lane, at: position, among: shown, standard: standard)
|
|
}
|
|
}
|
|
.padding(spacing)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
.background(boardBackground)
|
|
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
|
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
|
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
|
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
|
}
|
|
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
|
|
// the strip is the window's content, not a control, and a rectangle around the whole board
|
|
// would read as an error state.
|
|
.focusable()
|
|
.focusEffectDisabled()
|
|
.focused($isBoardFocused)
|
|
.onAppear { isBoardFocused = true }
|
|
.onChange(of: store.isEditingInline) { _, editing in
|
|
// An editor took focus and has now given it back. Without this the strip stays unfocused
|
|
// after every rename and Return silently stops working.
|
|
if !editing { isBoardFocused = true }
|
|
}
|
|
.onKeyPress(.return) { handleReturn() }
|
|
.onKeyPress(.escape) { handleEscape() }
|
|
}
|
|
|
|
// MARK: - Styling
|
|
|
|
/// The board's `background`, painting "the board window's content background (the surface behind
|
|
/// and between lanes)" (03-board-ui.md § Styling ▸ Capabilities).
|
|
///
|
|
/// Unlike the lane band and the card stripe this one is a **fill**, because at board level that
|
|
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
|
/// its ≥ 4.5:1 rule to: text does sit on it. That runtime contrast computation (a hex background's
|
|
/// text colour, recomputed against the composited backdrop on appearance change) is not this
|
|
/// card's — what ships here is the palette path, whose twelve pairs are AA-verified at design
|
|
/// time.
|
|
///
|
|
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
|
|
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
|
@ViewBuilder
|
|
private var boardBackground: some View {
|
|
if let color = Palette.color(for: store.snapshot.background) {
|
|
color
|
|
}
|
|
}
|
|
|
|
// MARK: - Lanes
|
|
|
|
/// One lane's strip slot, plus its trailing grab strip.
|
|
///
|
|
/// Normally a plain `LaneView` sized to its unit count's slot width (a wide lane swallows the
|
|
/// interior gaps it spans). While THIS lane is being resized the slot holds two layers, kept
|
|
/// structurally stable so the `LaneView` never loses identity — its scroll position, its
|
|
/// masonry cache — across the drag:
|
|
///
|
|
/// • a shadow at the SNAPPED slot width, full strip height, behind the lane — the resting
|
|
/// footprint the siblings and the window are already aligned to;
|
|
/// • the live `LaneView` in front at `liveWidth`, which tracks the cursor and so overflows
|
|
/// (drawing over the right neighbour, hence the slot's `zIndex(1)`) or underfills the shadow
|
|
/// between ticks.
|
|
///
|
|
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
|
|
/// off the tidy snapped layout regardless of the live overflow.
|
|
///
|
|
/// While this lane is being **reordered** the slot instead keeps its resting size and travels:
|
|
/// the offset is the gap between where the pointer has carried it and where it would rest under
|
|
/// the current proposal, so it tracks the cursor 1:1 while its siblings sit in the would-be
|
|
/// order beneath it (`zIndex(2)`, above even a resize).
|
|
@ViewBuilder
|
|
private func laneSlot(_ lane: Lane, at position: Int, among shown: [Lane], standard: CGFloat) -> some View {
|
|
let resizing = resize.isResizing(lane.id)
|
|
let dragging = reorder.isDragging(lane.id)
|
|
let units = resizing ? resize.units : LaneLayoutMath.displayUnits(of: lane)
|
|
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
|
|
ZStack(alignment: .topLeading) {
|
|
if resizing {
|
|
LaneResizeShadow()
|
|
.frame(width: slotWidth)
|
|
.frame(maxHeight: .infinity)
|
|
.allowsHitTesting(false)
|
|
}
|
|
// Interior columns follow the SNAPPED unit count while this lane is being resized — a
|
|
// column count is integral, so it tracks k (which ticks and animates), not the live
|
|
// continuous width and not the not-yet-committed `lane.width`. The live width still
|
|
// narrows and widens the columns continuously, so the cards reflow under the cursor
|
|
// between ticks (free via `MasonryLayout`).
|
|
LaneView(
|
|
store: store,
|
|
lane: lane,
|
|
columns: units,
|
|
reorder: reorder,
|
|
headerDrag: headerDrag(at: position, among: shown, standard: standard),
|
|
openCard: openCard
|
|
)
|
|
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
|
}
|
|
.frame(width: slotWidth, alignment: .topLeading)
|
|
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
|
|
.opacity(dragging ? 0.9 : 1)
|
|
.zIndex(dragging ? 2 : (resizing ? 1 : 0))
|
|
.overlay(alignment: .trailing) {
|
|
LaneResizeHandle(
|
|
store: store,
|
|
session: resize,
|
|
laneID: lane.id,
|
|
committedUnits: LaneLayoutMath.displayUnits(of: lane),
|
|
standard: standard,
|
|
gap: spacing,
|
|
window: window
|
|
)
|
|
// The read-only lock disables every mutating gesture, not just the menu items
|
|
// (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag
|
|
// resizes the *window* on the way, so a refused commit would leave the window grown
|
|
// around a lane that snapped back — and the lock's row is already saying why nothing
|
|
// can be written. The focused-editor rule closes it too, like every board command, and
|
|
// so does a reorder in flight: two drags mutating one strip layout is not a state this
|
|
// view has a meaning for.
|
|
.disabled(store.isReadOnly || store.isEditingInline || reorder.isActive)
|
|
}
|
|
}
|
|
|
|
/// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here** —
|
|
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane
|
|
/// (a later card), and a lane that is not on the board consumes none of the window's width.
|
|
private var liveLanes: [Lane] {
|
|
store.snapshot.lanes.filter { !$0.isDeleted }
|
|
}
|
|
|
|
// MARK: - Reorder
|
|
|
|
/// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in
|
|
/// flight. A drag whose lane has vanished from the snapshot shows the plain order and proposes
|
|
/// nothing — its release then cancels (04 ▸ Drag and drop, "an emptied drag cancels itself").
|
|
private func shownLanes(_ lanes: [Lane], standard: CGFloat) -> [Lane] {
|
|
guard let (from, to) = proposal(among: lanes, standard: standard) else { return lanes }
|
|
return LaneReorderMath.reordered(lanes, from: from, to: to)
|
|
}
|
|
|
|
/// Where the dragged lane sits in `lanes` and where it would land — `nil` when no reorder is in
|
|
/// flight, or when the lane it is carrying is no longer on the board.
|
|
private func proposal(among lanes: [Lane], standard: CGFloat) -> (from: Int, to: Int)? {
|
|
guard let id = reorder.laneID, let from = lanes.firstIndex(where: { $0.id == id }) else { return nil }
|
|
let to = LaneReorderMath.proposedIndex(
|
|
unitCounts: unitCounts(of: lanes),
|
|
draggedIndex: from,
|
|
dragCentreX: reorder.centre,
|
|
standard: standard,
|
|
gap: spacing
|
|
)
|
|
return (from, to)
|
|
}
|
|
|
|
/// How far the travelling lane is drawn from the slot it would rest in — the pointer's position
|
|
/// minus the proposal's. Zero at the instant a tick lands, growing again as the pointer moves
|
|
/// on, which is what makes the replica read as *held* rather than as snapping.
|
|
private func travelOffset(at position: Int, among shown: [Lane], standard: CGFloat) -> CGFloat {
|
|
reorder.centre - LaneReorderMath.centre(
|
|
ofLaneAt: position,
|
|
unitCounts: unitCounts(of: shown),
|
|
standard: standard,
|
|
gap: spacing
|
|
)
|
|
}
|
|
|
|
/// The strip's half of a lane header's drag: where the lane rests now, and what a release means.
|
|
private func headerDrag(at position: Int, among shown: [Lane], standard: CGFloat) -> LaneHeaderDrag {
|
|
LaneHeaderDrag(
|
|
startCentre: {
|
|
LaneReorderMath.centre(
|
|
ofLaneAt: position,
|
|
unitCounts: unitCounts(of: shown),
|
|
standard: standard,
|
|
gap: spacing
|
|
)
|
|
},
|
|
commit: { commitReorder(standard: standard) }
|
|
)
|
|
}
|
|
|
|
/// Releases the drag: re-derive the proposal against the snapshot **as it is now** and write it.
|
|
///
|
|
/// Re-deriving rather than trusting the last rendered proposal is 04-interactions.md ▸ Drag and
|
|
/// drop's re-grounding rule at its most consequential moment: a reload that landed between the
|
|
/// last render and the release must not be written over. A lane that vanished in that window
|
|
/// yields no proposal and the release simply cancels — "release with no valid proposal cancels;
|
|
/// items return, nothing is written".
|
|
///
|
|
/// `BoardStore.moveLane` owns the rest, the unchanged-index no-op included.
|
|
private func commitReorder(standard: CGFloat) {
|
|
defer { reorder.end() }
|
|
guard let id = reorder.laneID,
|
|
let (_, to) = proposal(among: liveLanes, standard: standard)
|
|
else { return }
|
|
store.moveLane(id, toIndex: to)
|
|
}
|
|
|
|
private func unitCounts(of lanes: [Lane]) -> [Int] {
|
|
lanes.map { LaneLayoutMath.displayUnits(of: $0) }
|
|
}
|
|
|
|
// MARK: - Grammar keys
|
|
|
|
/// **Return**, narrowly (04-interactions.md ▸ Grammar): a sole selected live card begins an
|
|
/// inline rename, a sole selected live lane begins a new-card placeholder at its bottom, and
|
|
/// everything else is ignored — a multi-card selection is explicitly inert, and a lane's rename
|
|
/// path is Board ▸ Rename precisely because Return on a lane creates.
|
|
///
|
|
/// The full keyboard map — arrows, ⌥-jumps, the ⌥↑ escalation, ⌫, the ⌥⌘ moves — is **m5's
|
|
/// keyboard-grammar card**. This is the creation/rename pair and nothing else.
|
|
///
|
|
/// Inert while an inline editor is open: "all grammar keys inert while a title editor is
|
|
/// focused". The field consumes Return itself, so this guard is belt over braces — but the belt
|
|
/// matters, because a stray Return reaching here mid-edit would open a *second* editor.
|
|
private func handleReturn() -> KeyPress.Result {
|
|
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
|
let selection = store.selection
|
|
guard selection.liveness == .live,
|
|
selection.ids.count == 1,
|
|
let id = selection.ids.first,
|
|
let target = BoardStore.liveItem(id, in: store.snapshot)
|
|
else { return .ignored }
|
|
|
|
if target.cardID == nil {
|
|
store.transient.beginPlaceholder(inLane: target.laneID)
|
|
} else {
|
|
store.transient.beginRename(of: id, currentTitle: target.title)
|
|
}
|
|
return .handled
|
|
}
|
|
|
|
/// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else
|
|
/// clear the selection.
|
|
///
|
|
/// The middle step — clearing an active search and returning focus to the board — is m5's, and
|
|
/// it slots between these two once the search field exists.
|
|
///
|
|
/// The editors handle Escape themselves while they hold focus; this branch is the outer net for
|
|
/// the case where focus has drifted off the field with an editor still open, and it abandons
|
|
/// both kinds because at most one can be open at a time.
|
|
private func handleEscape() -> KeyPress.Result {
|
|
if store.isEditingInline {
|
|
store.transient.discardPlaceholder()
|
|
store.transient.discardRename()
|
|
return .handled
|
|
}
|
|
guard !store.selection.isEmpty else { return .ignored }
|
|
store.clearSelection()
|
|
return .handled
|
|
}
|
|
}
|
|
|
|
// MARK: - Resize shadow
|
|
|
|
/// The resting footprint a lane snaps back to, drawn behind the live lane during a resize.
|
|
///
|
|
/// Minimal on purpose: 03-board-ui.md's placeholder/drag vocabulary lands with the drag milestone,
|
|
/// and this is the same shape that card and lane drops will want. Kept here rather than invented
|
|
/// twice.
|
|
private struct LaneResizeShadow: View {
|
|
var body: some View {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(.quaternary.opacity(0.5))
|
|
}
|
|
}
|