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
103 lines
5.2 KiB
Swift
103 lines
5.2 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|