Build the full-visibility lane layout
The lane strip replaces the placeholder board: window width divides
across the lanes' width units (no horizontal scroll, no minimum width,
degenerate compression accepted), a lane of n units flowing its cards
into n round-robin masonry columns via a measurement-cached Layout.
Width has two deliberately opposite controls, both landing here: the
right-edge drag (ported verbatim from the pathfinder's ColumnResize)
freezes the 1x standard at drag start, snaps between integer widths
with the asymmetric shadow-leads tick and 10pt re-entry, grows the
window one standard width per snap so siblings keep their exact
pixels, and rubber-bands at the screen's visible frame — uncapped
otherwise; the Increase/Decrease Lane Width items (new Board menu,
Cmd-Opt-arrows) are the stepper's keyboard face and re-divide the
existing window width instead, never touching the window. Width
changes write through the new .resize WriteOperation ("Couldn't
resize…" in the banner vocabulary, which grows with the surfaces by
design); malformed width values render as one unit and stay untouched
on disk. 27 new tests port the pathfinder's resize-math suite onto
the uncapped range and pin the write path's fidelity.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -11,9 +11,9 @@ import os
|
||||
///
|
||||
/// Almost everything here is about beginning and ending: acquiring the shared store, stamping the
|
||||
/// registry, holding the board's security-scoped access, remembering the window's frame, and running
|
||||
/// the close flush before any of it is let go. The board *itself* — lanes, cards, drag, the whole of
|
||||
/// 03-board-ui.md — is a placeholder below, deliberately throwaway and confined to one small view so
|
||||
/// the milestone that builds the real thing replaces exactly that and nothing else.
|
||||
/// the close flush before any of it is let go. The board *itself* — the lane strip and everything in
|
||||
/// it — is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out
|
||||
/// of the way.
|
||||
///
|
||||
/// ### Failure opens welcome
|
||||
///
|
||||
@@ -63,8 +63,13 @@ struct BoardWindowHost: View {
|
||||
case let .open(store):
|
||||
VStack(spacing: 0) {
|
||||
BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) }
|
||||
PlaceholderBoardView(store: store)
|
||||
// The window is handed to the board as a closure, not a value: `WindowAccessor`
|
||||
// attaches after this body first runs, and the lane-resize drag needs the *live*
|
||||
// window to grow at its right edge (03-board-ui.md § Lane).
|
||||
BoardView(store: store, window: { windowController.window })
|
||||
}
|
||||
// "The board in front", for the menu items that act on it (`LaneWidthCommands`).
|
||||
.focusedSceneValue(\.boardStore, store)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,54 +167,3 @@ struct BoardWindowHost: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The placeholder board
|
||||
|
||||
/// Stand-in for the board (03-board-ui.md): the title and a list of lane titles, and nothing else.
|
||||
///
|
||||
/// **Deliberately throwaway.** The next milestone builds the full-visibility lane layout — masonry
|
||||
/// cards, drag, the trash quasi-lane, the toolbar — and replaces this view wholesale. It is kept in
|
||||
/// one small view with no state of its own so that replacement is a deletion rather than an
|
||||
/// untangling. What it does prove today is that the window renders the *store's* snapshot: an
|
||||
/// external edit shows up here through the watcher like it will in the real thing.
|
||||
private struct PlaceholderBoardView: View {
|
||||
|
||||
let store: BoardStore
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(AppModel.displayName(of: store))
|
||||
.font(.largeTitle)
|
||||
|
||||
if liveLanes.isEmpty {
|
||||
Text("No lanes yet")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(liveLanes) { lane in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(lane.title.value ?? "Untitled")
|
||||
.font(.headline)
|
||||
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
||||
Text("\(liveCardCount(in: lane))")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tombstoned lanes render nowhere on the board (03-board-ui.md collapses them into the trash
|
||||
/// quasi-lane) — true of the placeholder as much as of the real layout.
|
||||
private var liveLanes: [Lane] {
|
||||
store.snapshot.lanes.filter { !$0.isDeleted }
|
||||
}
|
||||
|
||||
private func liveCardCount(in lane: Lane) -> Int {
|
||||
lane.cards.filter { !$0.isDeleted }.count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ struct KanbanApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two menu items this milestone owns.
|
||||
/// The menu items the app has built so far.
|
||||
///
|
||||
/// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts
|
||||
/// mechanism remaps menu items *by title*, so these strings are the keys a user's custom binding
|
||||
@@ -114,6 +114,13 @@ struct KanbanApp: App {
|
||||
.keyboardShortcut("o", modifiers: .command)
|
||||
}
|
||||
|
||||
// The Board menu (11-command-nexus.md). Its items act on the frontmost board window, which
|
||||
// they reach through the focus system rather than through the app model — see
|
||||
// `LaneWidthCommands`, which also owns their validation.
|
||||
CommandMenu("Board") {
|
||||
LaneWidthCommands()
|
||||
}
|
||||
|
||||
CommandGroup(after: .windowList) {
|
||||
// No default chord — "— (no default)" in the Nexus is deliberate, not a gap; it remaps
|
||||
// like any other item.
|
||||
|
||||
@@ -447,6 +447,8 @@ public final class BannerCenter {
|
||||
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
|
||||
case let .style(title):
|
||||
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
||||
case let .resize(title):
|
||||
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
|
||||
case let .importAttachment(filename):
|
||||
"Couldn't import '\(filename)'"
|
||||
case .listAttachments:
|
||||
|
||||
@@ -605,6 +605,46 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lane width
|
||||
|
||||
/// Writes a lane's width — the one commit point both width mechanisms share (03-board-ui.md §
|
||||
/// Lane): the right-edge drag's release and the stepper's ⌥⌘→/⌥⌘← both land here, and they differ
|
||||
/// only in what they did to the *window* on the way (the drag grew it, the stepper did not).
|
||||
///
|
||||
/// **The value written is an integer, replacing whatever was there.** `width` is a lenient field
|
||||
/// on the read side — missing, malformed, zero and negative all render as one unit
|
||||
/// (`LaneLayoutMath.displayUnits`) with the author's bytes left alone — but an explicit width
|
||||
/// change is the user overwriting that value, so the Writer puts a plain integer in its place
|
||||
/// (01-storage-format.md § Frontmatter).
|
||||
///
|
||||
/// Three ways this does nothing, all deliberate: a count below 1 clamps to 1 (a lane spans at
|
||||
/// least one unit), an id that is not in the snapshot is ignored (the lane vanished under the
|
||||
/// gesture — the reload that removed it is the authority), and a count already equal to what the
|
||||
/// lane displays writes nothing (a drag that ends where it started must not stamp `modified` or
|
||||
/// mint a git commit).
|
||||
///
|
||||
/// Failures are already the banner's: `performWrite` posts every `BoardWriteError` before it
|
||||
/// rethrows, so the rethrow is swallowed here rather than propagated to a gesture that has no
|
||||
/// second thing to do about it. The lane stays at its old width, which is the truth — nothing was
|
||||
/// written.
|
||||
public func setLaneWidth(_ id: ItemID, units: Int) {
|
||||
let clamped = max(1, units)
|
||||
guard let lane = snapshot.lanes.first(where: { $0.id == id }),
|
||||
LaneLayoutMath.displayUnits(of: lane) != clamped
|
||||
else { return }
|
||||
|
||||
let folder = rootURL.appendingPathComponent(id.rawValue)
|
||||
// The closure's signature is spelled out because of `try?`: with the error discarded at the
|
||||
// call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any
|
||||
// Error`, which `performWrite` will not take. Same wart as the value-returning call sites
|
||||
// `performWrite`'s doc comment records, arriving from the other direction.
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.width, to: .int(clamped))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection (delegated)
|
||||
|
||||
// The three thin pass-throughs to `transient`, and the only ones.
|
||||
|
||||
@@ -1192,6 +1192,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case restore(title: String?)
|
||||
case purge(title: String?)
|
||||
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
|
||||
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
||||
case importAttachment(filename: String)
|
||||
case listAttachments
|
||||
case renumberChildren // order-maintenance sweep (compaction)
|
||||
@@ -1216,6 +1217,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .restore: .restore(title: title)
|
||||
case .purge: .purge(title: title)
|
||||
case .style: .style(title: title)
|
||||
case .resize: .resize(title: title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,6 +1239,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .restore(title): Self.phrase("restore", title)
|
||||
case let .purge(title): Self.phrase("purge", title)
|
||||
case let .style(title): Self.phrase("style", title)
|
||||
case let .resize(title): Self.phrase("resize", title)
|
||||
case let .importAttachment(filename): "import attachment '\(filename)'"
|
||||
case .listAttachments: "list attachments"
|
||||
case .renumberChildren: "renumber children"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The focused board
|
||||
|
||||
/// The frontmost board window's store, published into the focus system by `BoardWindowHost` so menu
|
||||
/// items can act on "the board in front" without the app model keeping a which-window-is-key
|
||||
/// register of its own.
|
||||
///
|
||||
/// `focusedSceneValue` rather than `focusedValue`: the value is the *window's*, not any particular
|
||||
/// control's, so it stays available whatever inside the board has keyboard focus — which is what a
|
||||
/// menu item validating against the selection needs.
|
||||
struct FocusedBoardStoreKey: FocusedValueKey {
|
||||
typealias Value = BoardStore
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var boardStore: BoardStore? {
|
||||
get { self[FocusedBoardStoreKey.self] }
|
||||
set { self[FocusedBoardStoreKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lane width items
|
||||
|
||||
/// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane,
|
||||
/// 11-command-nexus.md), and therefore the *re-divide* mechanism: each step re-divides the existing
|
||||
/// window width across the new unit total, compressing the siblings. They never touch the window's
|
||||
/// size — window-growing behaviour belongs to the right-edge drag alone — and they are uncapped, so
|
||||
/// widths beyond what the screen can fit stay reachable here even though the drag hard-stops.
|
||||
///
|
||||
/// **Validation is the sole-selected-lane rule.** Both items are enabled only when the focused
|
||||
/// board's selection resolves to exactly one live lane; a card selection, a multi-selection, a
|
||||
/// trash-side selection and an empty one all disable them. Decrease additionally disables at one
|
||||
/// unit, which is the floor. Nothing selects a lane yet — the lane-chrome card wires the header
|
||||
/// click (04-interactions.md ▸ Selection) — so these validate-disable in today's build, which is
|
||||
/// expected rather than broken.
|
||||
struct LaneWidthCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
|
||||
var body: some View {
|
||||
Button("Increase Lane Width") {
|
||||
step(by: 1)
|
||||
}
|
||||
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
|
||||
.disabled(selectedLane == nil)
|
||||
|
||||
Button("Decrease Lane Width") {
|
||||
step(by: -1)
|
||||
}
|
||||
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
|
||||
.disabled(!canDecrease)
|
||||
}
|
||||
|
||||
/// The sole selected live lane, or `nil` — the whole of these items' validation.
|
||||
///
|
||||
/// A read-only board disables every mutating command (02-architecture.md § The lock's scope), so
|
||||
/// the lock is folded in here rather than left for the write to refuse: an item that is going to
|
||||
/// fail should not look available.
|
||||
private var selectedLane: Lane? {
|
||||
guard let store, !store.isReadOnly else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
|
||||
return store.snapshot.lanes.first { $0.id == id && !$0.isDeleted }
|
||||
}
|
||||
|
||||
/// A one-unit lane cannot shrink: `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only
|
||||
/// outcome is a no-op reads better disabled than dead.
|
||||
private var canDecrease: Bool {
|
||||
guard let lane = selectedLane else { return false }
|
||||
return LaneLayoutMath.displayUnits(of: lane) > 1
|
||||
}
|
||||
|
||||
private func step(by delta: Int) {
|
||||
guard let store, let lane = selectedLane else { return }
|
||||
store.setLaneWidth(lane.id, units: LaneLayoutMath.displayUnits(of: lane) + delta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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`).
|
||||
///
|
||||
/// ### What is deliberately not here yet
|
||||
///
|
||||
/// Selection, drag and drop, the trash quasi-lane, the toolbar, search, styling and the lane context
|
||||
/// menu all belong to later milestone cards. This view is the layout and the resize interaction, and
|
||||
/// the chrome inside `LaneView` is a placeholder those cards replace.
|
||||
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?
|
||||
|
||||
/// 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()
|
||||
|
||||
/// 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)
|
||||
HStack(alignment: .top, spacing: spacing) {
|
||||
ForEach(lanes) { lane in
|
||||
laneSlot(lane, standard: standard)
|
||||
}
|
||||
}
|
||||
.padding(spacing)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@ViewBuilder
|
||||
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
||||
let resizing = resize.isResizing(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(lane: lane, columns: units)
|
||||
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
||||
}
|
||||
.frame(width: slotWidth, alignment: .topLeading)
|
||||
.zIndex(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.
|
||||
.disabled(store.isReadOnly)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: - 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import CoreGraphics
|
||||
|
||||
/// The board strip's geometry, as pure arithmetic — no view, no window, no state
|
||||
/// (`LaneLayoutMathTests`).
|
||||
///
|
||||
/// Two rules from 03-board-ui.md meet here, and they are deliberately *different* mechanisms
|
||||
/// sharing one set of numbers:
|
||||
///
|
||||
/// - **Full visibility** (§ Layout — full visibility): the window's width divides across the lanes'
|
||||
/// width units, so `standardWidth` is the whole of the resting layout. There is no horizontal
|
||||
/// scroll and no minimum lane width to honour — enough units in a small window compress every
|
||||
/// lane, and that is accepted rather than floored.
|
||||
/// - **The right-edge drag** (§ Lane): a snap between whole unit counts that grows or shrinks the
|
||||
/// *window* by one standard width per tick, so the other lanes keep their exact pixels.
|
||||
/// `slotWidth`, `snappedUnits`, `resistedWidth` and `maxUnits` are that interaction's arithmetic,
|
||||
/// ported from the pathfinder's proven `ColumnResizeMath` (its reasoning is reproduced below,
|
||||
/// since the behaviour is what was proven, not the code).
|
||||
///
|
||||
/// The one behavioural difference from the pathfinder: **Lanework has no upper width cap.** A lane
|
||||
/// spans any whole number of units ≥ 1, so `allowedRange`'s ceiling is only ever the on-screen fit
|
||||
/// (`maxUnits`) — there is no `Column.widthRange` equivalent to fold in, and shrinking is always
|
||||
/// allowed.
|
||||
enum LaneLayoutMath {
|
||||
|
||||
// MARK: - The resting layout
|
||||
|
||||
/// The 1× (one unit) lane width for a strip `stripWidth` points wide laying out `totalUnits`
|
||||
/// whole units with `gap` between lanes **and `gap` again outside the first and the last** —
|
||||
/// hence `totalUnits + 1` gaps: the `totalUnits - 1` interior ones plus the strip's two outer
|
||||
/// margins. The strip therefore always exactly fills, which is what "every lane is always on
|
||||
/// screen" means arithmetically (03-board-ui.md § Layout — full visibility).
|
||||
///
|
||||
/// **Floored at 1pt, and at nothing else.** The design is explicit that the degenerate case is
|
||||
/// accepted, not floored: a minimum lane width would reintroduce horizontal scroll, which was
|
||||
/// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame
|
||||
/// is never zero or negative — the pathological input (a strip narrower than its own gaps) must
|
||||
/// not produce a negative size for SwiftUI to complain about.
|
||||
static func standardWidth(stripWidth: CGFloat, totalUnits: Int, gap: CGFloat) -> CGFloat {
|
||||
let count = CGFloat(max(1, totalUnits))
|
||||
return max(1, (stripWidth - gap * (count + 1)) / count)
|
||||
}
|
||||
|
||||
/// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1`
|
||||
/// interior gaps it swallows. `BoardView`'s per-lane frame is this exact expression, so a
|
||||
/// snapped slot and a committed lane are the same pixels.
|
||||
static func slotWidth(units: Int, standard: CGFloat, gap: CGFloat) -> CGFloat {
|
||||
standard * CGFloat(units) + gap * CGFloat(units - 1)
|
||||
}
|
||||
|
||||
/// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1
|
||||
/// otherwise.
|
||||
///
|
||||
/// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key, a
|
||||
/// non-numeric value, a fraction, a zero or a negative all arrive here as `.missing` or
|
||||
/// `.malformed` and render as one unit — the bytes on disk are left exactly as the author wrote
|
||||
/// them until the user actually changes the width, at which point the Writer replaces them with
|
||||
/// an integer (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side
|
||||
/// already refuses anything below 1, and this function is the single place the rest of the UI
|
||||
/// asks "how many units does this lane span".
|
||||
static func displayUnits(of lane: Lane) -> Int {
|
||||
max(1, lane.width.value ?? 1)
|
||||
}
|
||||
|
||||
/// The unit total a strip of `lanes` divides across — the sum of their display units, never
|
||||
/// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
|
||||
///
|
||||
/// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because
|
||||
/// a tombstoned lane renders nowhere on the board (03-board-ui.md § Trash collapses it to a
|
||||
/// single trash entry) and so consumes none of the window's width. When the trash quasi-lane
|
||||
/// arrives it joins this total as one fixed unit — "Show/Hide Trash is a re-divide trigger".
|
||||
static func totalUnits(of lanes: [Lane]) -> Int {
|
||||
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) })
|
||||
}
|
||||
|
||||
// MARK: - The drag's snap
|
||||
|
||||
/// The snapped unit count after a live-width change: `currentUnits` unless the live width has
|
||||
/// moved far enough into "shadow leads" territory for an adjacent slot, in which case it ticks
|
||||
/// by exactly one.
|
||||
///
|
||||
/// This is an ASYMMETRIC snap, not a midpoint-±-band around a boundary: the shadow (the snapped
|
||||
/// count, which sizes the visible slot) leads the live edge going up and deliberately lags it
|
||||
/// coming back down.
|
||||
///
|
||||
/// • Tick UP fires the instant the live edge clears the FAR side of the gap that trails slot
|
||||
/// `k` — `liveWidth > slotWidth(k) + gap` — which is exactly where the next lane's content
|
||||
/// would start. The moment the cursor has eaten the whole gap, the bigger slot is already
|
||||
/// the honest read of what is under it, so the shadow jumps there right away: it never
|
||||
/// lags, and the live edge can only ever overhang the shadow by at most one gap width,
|
||||
/// transiently, in the instant just before a tick.
|
||||
/// • Tick DOWN fires only once the live edge has retreated `reentry` (10pt, fixed) back INTO
|
||||
/// that same gap — `liveWidth < slotWidth(k - 1) + gap - reentry` — rather than at the
|
||||
/// mirror image of the tick-up threshold. Shrinking back the instant the edge re-enters the
|
||||
/// gap it just cleared would flap the window on the smallest jitter right at the crossing;
|
||||
/// requiring a real 10pt of retreat means the cursor has to mean it. (03-board-ui.md § Lane
|
||||
/// names exactly this: "shadow snaps at the inter-column gap with 10pt release hysteresis".)
|
||||
///
|
||||
/// `reentry` is the ONLY hysteresis in this design — it exists to give the tick-down threshold
|
||||
/// room, not to make the two thresholds symmetric. Stability follows from the two thresholds
|
||||
/// never meeting: for shadow `k` the hold band is `(slotWidth(k - 1) + gap - reentry,
|
||||
/// slotWidth(k) + gap]`, which stays non-empty as long as `standard` is many times larger than
|
||||
/// 10pt (true of every lane width a real window produces), so calling this on every drag event
|
||||
/// never oscillates.
|
||||
///
|
||||
/// Ticks are capped to `allowedRange`, which in Lanework folds in **only** the on-screen fit
|
||||
/// (`maxUnits`) — there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, … — no
|
||||
/// cap"), and the uncapped widths beyond the screen's capacity are the stepper's business, not
|
||||
/// the drag's.
|
||||
static func snappedUnits(
|
||||
liveWidth: CGFloat,
|
||||
currentUnits: Int,
|
||||
standard: CGFloat,
|
||||
gap: CGFloat,
|
||||
allowedRange: ClosedRange<Int>,
|
||||
reentry: CGFloat
|
||||
) -> Int {
|
||||
let currentSlot = slotWidth(units: currentUnits, standard: standard, gap: gap)
|
||||
if currentUnits < allowedRange.upperBound, liveWidth > currentSlot + gap {
|
||||
return currentUnits + 1
|
||||
}
|
||||
if currentUnits > allowedRange.lowerBound {
|
||||
let previousSlot = slotWidth(units: currentUnits - 1, standard: standard, gap: gap)
|
||||
if liveWidth < previousSlot + gap - reentry {
|
||||
return currentUnits - 1
|
||||
}
|
||||
}
|
||||
return currentUnits
|
||||
}
|
||||
|
||||
/// The live width rubber-banded to stay near the allowed slot range: inside `[minSlot,
|
||||
/// maxSlot]` the proposed width passes through untouched; beyond either end only `resistance`
|
||||
/// (0.25) of the overshoot is applied, so the edge visibly resists but still gives, signalling
|
||||
/// the bound without a hard stop (03-board-ui.md § Lane: "Growth hard-stops at the screen's
|
||||
/// visible frame, with rubber-band feedback"). The snap tick never follows the width past the
|
||||
/// bound (see `snappedUnits`' clamp), so this is purely cosmetic give.
|
||||
static func resistedWidth(
|
||||
proposed: CGFloat,
|
||||
minSlot: CGFloat,
|
||||
maxSlot: CGFloat,
|
||||
resistance: CGFloat
|
||||
) -> CGFloat {
|
||||
if proposed < minSlot { return minSlot - (minSlot - proposed) * resistance }
|
||||
if proposed > maxSlot { return maxSlot + (proposed - maxSlot) * resistance }
|
||||
return proposed
|
||||
}
|
||||
|
||||
/// The largest unit count that fits on screen: `currentUnits` plus as many whole `step`
|
||||
/// (= standard + gap) growths as the window has room to expand into before its right edge would
|
||||
/// pass the screen's visible frame. **Never less than `currentUnits`** — shrinking is always
|
||||
/// allowed regardless of screen room, including from a window already hanging off the edge
|
||||
/// (negative headroom).
|
||||
///
|
||||
/// Unlike the pathfinder's twin there is no width ceiling to `min` against: the drag's only
|
||||
/// bound is the screen, because it is the mechanism that grows the window. Larger widths are
|
||||
/// reachable through the stepper, which re-divides instead (03-board-ui.md § Lane).
|
||||
///
|
||||
/// Pure so it can be unit-tested; the session computes `headroom` from the live window and its
|
||||
/// screen and defers the arithmetic here.
|
||||
static func maxUnits(currentUnits: Int, headroom: CGFloat, step: CGFloat) -> Int {
|
||||
guard step > 0, headroom.isFinite else { return currentUnits }
|
||||
let extra = Int(floor(max(0, min(headroom, CGFloat(Int.max) / 2)) / step))
|
||||
return max(currentUnits, currentUnits + extra)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// The invisible 12pt grab strip overlaid at a lane's trailing edge that drives a
|
||||
/// `LaneResizeSession` from a `DragGesture` (03-board-ui.md § Lane, right-edge drag-to-resize).
|
||||
///
|
||||
/// Overlaid so ~8pt hangs into the inter-lane gap and only ~4pt sits over the lane itself (clear of
|
||||
/// the lane's own vertical scrollbar, which lives at that inner edge). **Every lane gets one, the
|
||||
/// last included** — the rightmost lane's drag is the one that grows the window into free screen
|
||||
/// space, which is the interaction's headline case.
|
||||
///
|
||||
/// It carries no drop target of its own, so it never participates in card/lane/file drops, and as an
|
||||
/// overlay it hit-tests above the lane body's own gestures.
|
||||
struct LaneResizeHandle: View {
|
||||
|
||||
let store: BoardStore
|
||||
let session: LaneResizeSession
|
||||
|
||||
let laneID: ItemID
|
||||
|
||||
/// The lane's committed unit count — the k the session starts from.
|
||||
let committedUnits: Int
|
||||
|
||||
/// The strip's standard (1×) width THIS render; captured as the frozen standard the instant the
|
||||
/// drag begins.
|
||||
let standard: CGFloat
|
||||
let gap: CGFloat
|
||||
|
||||
/// How the session reaches the host window it resizes. A closure rather than a stored
|
||||
/// `NSWindow?` because the window attaches asynchronously (`WindowAccessor`), and a value
|
||||
/// captured in an early body evaluation would be `nil` for the window's whole life.
|
||||
let window: @MainActor () -> NSWindow?
|
||||
|
||||
/// Guards `NSCursor` push/pop balance — a fast cursor can leave the strip mid-drag, and the drag
|
||||
/// can end on or off it, so pushes and pops must be idempotent to never leave a stuck resize
|
||||
/// cursor.
|
||||
@State private var cursorPushed = false
|
||||
|
||||
private let handleWidth: CGFloat = 12
|
||||
|
||||
/// Rightward shift: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
|
||||
/// into the gap (clear of the scrollbar).
|
||||
private let overhang: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: handleWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.offset(x: overhang)
|
||||
.onHover { inside in
|
||||
if inside { pushCursor() } else { popCursor() }
|
||||
}
|
||||
// `.global` (window-fixed) space, NOT the handle's own: a tick moves the handle with its
|
||||
// lane, but the window's top-left is pinned (right-edge growth), so a window-fixed
|
||||
// translation stays a faithful physical-cursor delta throughout the drag.
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 2, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
if !session.isResizing(laneID) {
|
||||
// m5-drag: a resize and a card/lane move must not run at once — they
|
||||
// would both mutate the same strip layout. The board's drag state does
|
||||
// not exist yet; when it does, this is where the `isDragging` guard goes.
|
||||
pushCursor()
|
||||
session.begin(laneID: laneID, units: committedUnits,
|
||||
standard: standard, gap: gap, window: window())
|
||||
}
|
||||
session.update(translation: value.translation.width)
|
||||
}
|
||||
.onEnded { _ in
|
||||
guard session.isResizing(laneID) else { return }
|
||||
session.end { id, units in store.setLaneWidth(id, units: units) }
|
||||
popCursor()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func pushCursor() {
|
||||
guard !cursorPushed else { return }
|
||||
NSCursor.resizeLeftRight.push()
|
||||
cursorPushed = true
|
||||
}
|
||||
|
||||
private func popCursor() {
|
||||
guard cursorPushed else { return }
|
||||
NSCursor.pop()
|
||||
cursorPushed = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
/// Window-local state for an in-flight lane resize — the right-edge drag of 03-board-ui.md § Lane.
|
||||
/// At most one runs per board window at a time; `BoardView` owns it as `@State` and hands it to the
|
||||
/// lanes and their grab strips.
|
||||
///
|
||||
/// The interaction has three collaborating pieces: `LaneLayoutMath` (the pure geometry), this
|
||||
/// session (the live state plus the per-tick window resize), and `LaneResizeHandle` (the invisible
|
||||
/// grab strip that drives it from a `DragGesture`). All three are ported from the pathfinder's
|
||||
/// proven `ColumnResize.swift`, which is what 03-board-ui.md's "pathfinder behavior, proven" refers
|
||||
/// to.
|
||||
///
|
||||
/// ### The invariant that makes it feel solid
|
||||
///
|
||||
/// **While a session is active, every OTHER lane keeps its exact pixel width.** That is achieved by
|
||||
/// freezing the strip's standard (1×) width at drag start and sizing the *window* so that after
|
||||
/// each snap tick the ordinary viewport-derived formula reproduces that frozen standard exactly —
|
||||
/// so releasing the drag hands back to the resting layout with no pixel jump. This is the opposite
|
||||
/// mechanism from the stepper (and its ⌥⌘→/⌥⌘← keyboard face), which never touches the window and
|
||||
/// re-divides the existing width across the new unit total; the design is explicit that
|
||||
/// window-growing behaviour belongs to the drag alone.
|
||||
///
|
||||
/// The session owns the two things that must move together on each tick: the SwiftUI unit count
|
||||
/// (`units`, which drives the shadow slot, the siblings' positions, and the resizing lane's masonry
|
||||
/// column count) and the host window's width. They animate on matching 0.2s curves — the lane-resize
|
||||
/// entry in 03-board-ui.md § Motion's snappy-spring vocabulary — so the window edge and the lanes to
|
||||
/// its right travel as one.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LaneResizeSession {
|
||||
|
||||
/// The lane being resized; `nil` when idle. Observed — flipping it drives `BoardView`'s
|
||||
/// frozen-standard override and the shadow slot on and off, and `LaneView`'s column count.
|
||||
private(set) var laneID: ItemID?
|
||||
|
||||
/// The dragged lane's live rendered width — tracks the cursor continuously (rubber-banded at
|
||||
/// the ends), so its masonry reflows live between ticks.
|
||||
private(set) var liveWidth: CGFloat = 0
|
||||
|
||||
/// The snapped unit count k. Drives the shadow slot width, the layout slot the siblings
|
||||
/// position off, and the resizing lane's masonry column count. Ticks by ±1 and animates.
|
||||
private(set) var units: Int = 1
|
||||
|
||||
/// The strip's standard (1×) width, frozen at drag start. Used for ALL lane widths in
|
||||
/// `BoardView` while a session is active — the window is animating mid-session, so recomputing
|
||||
/// the standard from the live viewport width would feed the animation back into the layout and
|
||||
/// pulse every lane. Read within renders already triggered by the observed properties above, so
|
||||
/// it need not itself be observed.
|
||||
@ObservationIgnored private(set) var standard: CGFloat = 1
|
||||
|
||||
/// The strip's inter-lane gap (== `BoardView.spacing`), captured at begin.
|
||||
@ObservationIgnored private var gap: CGFloat = 12
|
||||
|
||||
/// The committed unit count at drag start — the anchor the drag translation is measured from.
|
||||
@ObservationIgnored private var startUnits: Int = 1
|
||||
|
||||
/// The largest unit count that fits on screen. The drag's only ceiling: Lanework's `width` has
|
||||
/// no cap (03-board-ui.md § Lane), so nothing else bounds growth.
|
||||
@ObservationIgnored private var fittingUnits: Int = 1
|
||||
|
||||
/// The host window, resized by ±(standard + gap) on each tick. Weak — a window can close,
|
||||
/// though a resize cannot outlive the gesture that drives it.
|
||||
@ObservationIgnored private weak var window: NSWindow?
|
||||
|
||||
var isActive: Bool { laneID != nil }
|
||||
|
||||
func isResizing(_ id: ItemID) -> Bool { laneID == id }
|
||||
|
||||
/// The tick-down re-entry distance: how far the live edge must retreat back into a gap it has
|
||||
/// already crossed before the shadow shrinks (03-board-ui.md § Lane's "10pt release
|
||||
/// hysteresis" — see `LaneLayoutMath.snappedUnits`). Fixed in points, not proportional to
|
||||
/// `standard`: it only needs to be comfortably larger than cursor jitter, which 10pt is
|
||||
/// regardless of lane size.
|
||||
private let reentry: CGFloat = 10
|
||||
|
||||
/// The rubber-band overshoot fraction past the end slots.
|
||||
private let resistance: CGFloat = 0.25
|
||||
|
||||
/// Unit counts the tick may reach: one up to the on-screen fit. The floor is 1 because a lane
|
||||
/// spans at least one unit; there is no ceiling but the screen.
|
||||
private var allowedRange: ClosedRange<Int> {
|
||||
1...max(1, fittingUnits)
|
||||
}
|
||||
|
||||
private var minSlot: CGFloat {
|
||||
LaneLayoutMath.slotWidth(units: allowedRange.lowerBound, standard: standard, gap: gap)
|
||||
}
|
||||
|
||||
private var maxSlot: CGFloat {
|
||||
LaneLayoutMath.slotWidth(units: allowedRange.upperBound, standard: standard, gap: gap)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Starts a resize of `laneID`, freezing the standard width and the gap and measuring how far
|
||||
/// the window can grow on its current screen.
|
||||
func begin(laneID: ItemID, units: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) {
|
||||
self.laneID = laneID
|
||||
self.startUnits = units
|
||||
self.units = units
|
||||
self.standard = standard
|
||||
self.gap = gap
|
||||
self.window = window
|
||||
self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
|
||||
self.fittingUnits = Self.fittingMaxUnits(currentUnits: units, standard: standard, gap: gap, window: window)
|
||||
}
|
||||
|
||||
/// Applies a drag translation (points, measured from the gesture's start): tracks the live width
|
||||
/// to the cursor with end-resistance, then ticks the snapped unit count to its fixed point and
|
||||
/// applies the result in one animated step.
|
||||
///
|
||||
/// A single call to `snappedUnits` only ever steps by ±1, but a fast flick can carry the live
|
||||
/// width across two (or more) thresholds between consecutive gesture events, so it is iterated
|
||||
/// here until it stops moving — bounded by `allowedRange`'s width, so this never loops more than
|
||||
/// a couple of times in practice. `tick(to:)` already computes a correct multi-step window-size
|
||||
/// delta from `units` to the target, so only the FINAL target gets one `tick` call, not one per
|
||||
/// intermediate step.
|
||||
func update(translation: CGFloat) {
|
||||
guard isActive else { return }
|
||||
let startSlot = LaneLayoutMath.slotWidth(units: startUnits, standard: standard, gap: gap)
|
||||
liveWidth = LaneLayoutMath.resistedWidth(
|
||||
proposed: startSlot + translation,
|
||||
minSlot: minSlot, maxSlot: maxSlot, resistance: resistance)
|
||||
var target = units
|
||||
while true {
|
||||
let next = LaneLayoutMath.snappedUnits(
|
||||
liveWidth: liveWidth, currentUnits: target,
|
||||
standard: standard, gap: gap, allowedRange: allowedRange, reentry: reentry)
|
||||
if next == target { break }
|
||||
target = next
|
||||
}
|
||||
if target != units { tick(to: target) }
|
||||
}
|
||||
|
||||
/// Commits the snapped unit count and dismisses the session. Order matters for a flash-free
|
||||
/// handoff: write the model FIRST (the session is still active, so the frozen standard still
|
||||
/// governs and the shadow slot does not budge), THEN clear the session inside the snap animation
|
||||
/// — at which point `BoardView` reverts to the viewport-derived standard, which the window
|
||||
/// sizing has kept equal to the frozen one, so the resting layout reproduces the same pixels
|
||||
/// while the live width animates the last sub-tick of overflow/underfill away. The window and
|
||||
/// the siblings are already in place; neither is touched here.
|
||||
///
|
||||
/// The commit is a *write*, not a snapshot mutation: it goes to disk through the Writer and
|
||||
/// comes back as an ordinary reload (02-architecture.md § Layering's one-way flow), so the lane
|
||||
/// briefly renders at its pre-drag width if the write fails — which is exactly the honesty the
|
||||
/// banner then explains.
|
||||
func end(commit: (ItemID, Int) -> Void) {
|
||||
guard let laneID else { return }
|
||||
commit(laneID, units)
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
self.laneID = nil
|
||||
self.liveWidth = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tick
|
||||
|
||||
/// A single snapped step: animate the unit count (which resizes the shadow slot, translates the
|
||||
/// lanes to the right, and reflows the resizing lane's interior columns) and the window's width
|
||||
/// on matching 0.2s curves. The window grows and shrinks at its RIGHT edge — width changes by
|
||||
/// ±step with `origin.x` and height held — so everything to the left, including this lane's own
|
||||
/// left edge and the drag's coordinate origin, stays put.
|
||||
private func tick(to newUnits: Int) {
|
||||
let delta = CGFloat(newUnits - units) * (standard + gap)
|
||||
withAnimation(.snappy(duration: 0.2)) { units = newUnits }
|
||||
guard let window else { return }
|
||||
var frame = window.frame
|
||||
frame.size.width += delta // right-edge growth: origin and height unchanged
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.2
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
context.allowsImplicitAnimation = true
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-screen fit, from the window's headroom to its screen's visible frame — the hard stop
|
||||
/// 03-board-ui.md § Lane requires ("Growth hard-stops at the screen's visible frame"). Defers
|
||||
/// the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current count is
|
||||
/// the honest answer (growth needs a window to grow).
|
||||
private static func fittingMaxUnits(currentUnits: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) -> Int {
|
||||
guard let window, let screen = window.screen ?? NSScreen.main else { return currentUnits }
|
||||
let headroom = screen.visibleFrame.maxX - window.frame.maxX
|
||||
return LaneLayoutMath.maxUnits(currentUnits: currentUnits, headroom: headroom, step: standard + gap)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import SwiftUI
|
||||
|
||||
/// One lane: a header and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
|
||||
///
|
||||
/// **The chrome here is deliberately minimal**, and the design's real lane is a later card: the
|
||||
/// leading SF Symbol, the new-card button, the drag surface, the context menu (Rename, Style…, the
|
||||
/// quick-style recents row, the Width stepper, Delete), the colour accent band, inline rename and
|
||||
/// the search-aware count all arrive with the lane-chrome milestone. What this view owes the
|
||||
/// full-visibility layout card is the shape — a header, a body that scrolls vertically only, and a
|
||||
/// masonry whose interior column count is the lane's width — and that is all it does.
|
||||
struct LaneView: View {
|
||||
|
||||
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
|
||||
|
||||
/// Spacing between cards, and between the interior columns.
|
||||
private let cardSpacing: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header
|
||||
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(liveCards) { card in
|
||||
CardStubView(card: card)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The header — title, or a quiet "Untitled" where there is none, plus the live card count.
|
||||
///
|
||||
/// Titles are optional at every level (03-board-ui.md § Card face): a missing `title` renders as
|
||||
/// a secondary-styled placeholder rather than as an empty row. **Replaced wholesale by the
|
||||
/// lane-chrome card**, which brings the icon, the count badge's real styling, the new-card
|
||||
/// button, the drag surface and the context menu.
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(lane.title.value ?? "Untitled")
|
||||
.font(.headline)
|
||||
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text("\(liveCards.count)")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
/// **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.
|
||||
private var liveCards: [Card] {
|
||||
lane.cards.filter { !$0.isDeleted }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card stub
|
||||
|
||||
/// A card, as a rounded plate with its title — **a stand-in, replaced by the card-face card**, which
|
||||
/// brings the leading icon, the attachment chip, the selection and cut treatments, inline rename and
|
||||
/// the sole-selected card's attachment carousel (03-board-ui.md § Card face).
|
||||
///
|
||||
/// It takes whatever width `MasonryLayout` proposes (one interior column = one standard width) and
|
||||
/// sizes its own height to its content, which is what makes the masonry masonry: a taller card only
|
||||
/// pushes the cards below it in its own column.
|
||||
private struct CardStubView: View {
|
||||
|
||||
let card: Card
|
||||
|
||||
var body: some View {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Masonry layout for a lane's interior card columns (03-board-ui.md § Layout — full visibility:
|
||||
/// "a wide lane flows them into as many interior masonry columns as it has units"; § Lane: "masonry
|
||||
/// grid when wide — settled, the pathfinder's masonry works").
|
||||
///
|
||||
/// Children are assigned round-robin to `columns` equal-width vertical columns (child `i` → column
|
||||
/// `i % columns`), and each column stacks its children top-aligned and independently — there is
|
||||
/// **no row alignment across columns**. With uniform card heights this renders exactly like a
|
||||
/// row-major grid, but when one card grows taller (the sole selected card's attachment carousel,
|
||||
/// 03-board-ui.md § Card face) it only pushes the cards below it in its *own* column; the
|
||||
/// neighbouring columns do not move.
|
||||
///
|
||||
/// A `Layout` rather than an `HStack` of per-column `VStack`s so the caller keeps a single
|
||||
/// `ForEach` — reflowing cards across columns preserves view identity and animates as positional
|
||||
/// moves, not as remove/insert transitions. That is what lets the interior reflow *during* a resize
|
||||
/// drag read as cards sliding rather than blinking.
|
||||
///
|
||||
/// **Vocabulary note.** In Lanework a "lane" is the kanban column; this layout's own interior
|
||||
/// tracks are "columns". The pathfinder called them lanes, which is why the ported reasoning below
|
||||
/// reads the way it does.
|
||||
struct MasonryLayout: Layout {
|
||||
|
||||
/// Number of interior columns (the lane's width units); clamped to ≥ 1.
|
||||
var columns: Int
|
||||
|
||||
/// Spacing between columns and between stacked cards within a column.
|
||||
var spacing: CGFloat
|
||||
|
||||
private var columnCount: Int { max(1, columns) }
|
||||
|
||||
private func columnWidth(for totalWidth: CGFloat) -> CGFloat {
|
||||
max(0, (totalWidth - spacing * CGFloat(columnCount - 1)) / CGFloat(columnCount))
|
||||
}
|
||||
|
||||
// MARK: - Measurement cache
|
||||
//
|
||||
// A lane with several hundred cards is measured a LOT: SwiftUI probes a layout's `sizeThatFits`
|
||||
// more than once per pass (different proposals), and `placeSubviews` needs every height again
|
||||
// right after. Unmemoized that is `subviews.count` full subtree measurements per call.
|
||||
//
|
||||
// The cache holds one height per subview, keyed by the column width they were measured at:
|
||||
// column width is the only thing this layout ever proposes (height is always `nil`, so a card's
|
||||
// height is a pure function of its width and its content). A different column width — a window
|
||||
// resize, a width change — discards the whole table, which is correct and cheap: it is exactly
|
||||
// the case where every height really did change.
|
||||
//
|
||||
// Staleness is handled by SwiftUI itself: `updateCache` runs whenever the layout's subviews
|
||||
// change, which is the only way a card's measured height can change at a fixed column width
|
||||
// (its content changed → its view tree was rebuilt → the layout's content is new). Clearing
|
||||
// there means the cache never outlives the content it measured.
|
||||
struct Cache {
|
||||
var columnWidth: CGFloat = .nan
|
||||
var heights: [CGFloat] = []
|
||||
}
|
||||
|
||||
func makeCache(subviews: Subviews) -> Cache { Cache() }
|
||||
|
||||
func updateCache(_ cache: inout Cache, subviews: Subviews) {
|
||||
cache = Cache()
|
||||
}
|
||||
|
||||
/// `subviews[index]`'s height at `column` width, measured once per (content, column width)
|
||||
/// generation and reused for every later probe and for the placement pass.
|
||||
private func height(of subviews: Subviews, at index: Int, column: CGFloat, cache: inout Cache) -> CGFloat {
|
||||
if cache.columnWidth != column || cache.heights.count != subviews.count {
|
||||
cache.columnWidth = column
|
||||
cache.heights = [CGFloat](repeating: .nan, count: subviews.count)
|
||||
}
|
||||
if !cache.heights[index].isNaN {
|
||||
return cache.heights[index]
|
||||
}
|
||||
let height = subviews[index].sizeThatFits(ProposedViewSize(width: column, height: nil)).height
|
||||
cache.heights[index] = height
|
||||
return height
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
|
||||
let width = proposal.width ?? 0
|
||||
let column = columnWidth(for: width)
|
||||
var heights = [CGFloat](repeating: 0, count: columnCount)
|
||||
for index in subviews.indices {
|
||||
let height = height(of: subviews, at: index, column: column, cache: &cache)
|
||||
let target = index % columnCount
|
||||
heights[target] += height + (heights[target] > 0 ? spacing : 0)
|
||||
}
|
||||
return CGSize(width: width, height: heights.max() ?? 0)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
|
||||
let column = columnWidth(for: bounds.width)
|
||||
var y = [CGFloat](repeating: bounds.minY, count: columnCount)
|
||||
for index in subviews.indices {
|
||||
let target = index % columnCount
|
||||
let x = bounds.minX + CGFloat(target) * (column + spacing)
|
||||
let height = height(of: subviews, at: index, column: column, cache: &cache)
|
||||
subviews[index].place(at: CGPoint(x: x, y: y[target]),
|
||||
proposal: ProposedViewSize(width: column, height: height))
|
||||
y[target] += height + spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user