Files
lanework/Kanban/App/TemplateChooserView.swift
T
rzen eef0a4539f Clicks land the instant they happen — the empty-space gestures move behind the masonry
The measured defect: LaneView's empty-space double-click was a second
sequential .onTapGesture(count: 2) stacked over the single tap, and that
recogniser held every click in the lane — its own empty space and every
card face alike — hostage to the system double-click interval while it
disambiguated (~475 ms click-to-selection on a hosted board). The fix is
structural: the empty-space surfaces live on a background layer behind the
masonry, so a card's click never shares a gesture path with a lane
recogniser; one .onTapGesture branches on PointerClick.count (AppKit's own
clickCount, read the way ClickModifier reads the keyboard) — first click
selects, second creates, Finder's cadence with nothing to disambiguate; and
the layer carries a load-bearing empty .onDrag, because without a drag
source macOS holds primary clicks pending multi-click disambiguation
(measured: never fires alone, ~90 ms with one present). A measured viewport
floor makes each lane's blank space actually belong to the layer — a
ScrollView proposes nothing along its scroll axis, so only an explicit
minimum stretches the content — with the trash column as its twin, less the
padding that sits inside its scroll content. The template chooser's stacked
pair collapses to the same one-recogniser branch, and the attachment rows
move their double-click to a simultaneous gesture (instant there, because
the row's real .onDrag forces immediate delivery). PointerLatencyTests pins
the recovery with synthetic pointer events on a hosted board.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:16 -04:00

513 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
import os
/// The template chooser — File ▸ New Board… (⌥⌘N), 09-templates.md's picker.
///
/// ### Pages' shape, over real board folders
///
/// A grid of template cards, each showing a **mini per-lane preview** above its name, one selected at
/// a time, with Cancel and Choose at the bottom (03-board-ui.md § Welcome screen & templates: "a
/// Pages-style chooser with a mini per-lane preview per template"). The grid is filled by
/// `TemplateEngine.chooserRows()` — the app bundle's `Templates/` folder, then the user store — each
/// entry loaded through the ordinary `BoardLoader`, so name, blurb, icon and preview all come off the
/// template board's own `index.md` rather than from a Swift catalog.
///
/// **The order is 09's, and it is the engine's** (09 ▸ Storage: "bundled templates by
/// `template.order`, then keyed user templates by `template.order`, then keyless user boards last,
/// sorted by display name"). This view renders the list it is handed and never re-sorts it.
///
/// ### One bad template never fails the chooser
///
/// A user-store folder the loader rejects is **listed anyway** — by folder name, marked unloadable,
/// carrying the loader's fail-fast specifics, not previewable and not choosable (09 ▸ Why this
/// format). The store is hand-editable, so a malformed board there is one edit away; a chooser that
/// refused to open, or that silently dropped the row, would leave the user with no way to see which
/// folder is broken or why. Selecting the row shows the loader's own sentence where a blurb would be.
///
/// ### Reveal in Finder, and how fresh the list is
///
/// 09 keeps the app-side store honest with "a **Reveal in Finder** affordance in the
/// template chooser": the button beside the header, which **creates the store and then reveals it**
/// (`TemplateEngine.createUserStore`) — the store's two minters are Save as Template and this, so a
/// user who has never saved one still gets a folder to drop a board into rather than a Finder window
/// full of nothing.
///
/// **The store is not watched** — 09 asks for a Reveal affordance, not a live folder — so the list is
/// re-read on every presentation and again whenever the app comes back to the front. The second is
/// the Reveal round trip made honest: the user reveals the folder, drops a board in, comes back, and
/// the row is there. A folder dropped in with this window already frontmost appears on the next
/// activation or the next opening, which is the documented minimum rather than an oversight.
///
/// ### Choosing is three steps, and the middle one is a save panel
///
/// Choose runs `NSSavePanel`, instantiates into the chosen location, and opens the result. The panel
/// is not ceremony: a sandboxed app cannot write anywhere the user has not pointed at, so the panel
/// *is* how a new board gets a location it is allowed to occupy (09 files this under "seed the save
/// panel's suggested name"). The chooser stays open if the panel is cancelled — a cancelled location
/// is not a cancelled choice.
///
// m9-templates: the in-progress row with Cancel that copy-shaped work is owed (02-architecture.md
// § The banner surface) still has nowhere to live here — this window has no banner surface, and the
// board that would host one does not exist yet. The engine takes the cancellation seam
// (`TemplateEngine.instantiate(…, isCancelled:)`) and this view runs the copy off the main actor, so
// the row has something to spin over when the window grows a strip. Save as Template, whose copy
// *does* have a board window behind it, already carries its row.
///
/// ### Failure is an alert here, deliberately
///
/// Everywhere else in the app a failed write is a banner in the window that produced it
/// (02-architecture.md § Write-failure surfacing). A failed *create* has no such window: the board
/// that would host the banner is the one that did not get created, welcome may not be open (⌥⌘N
/// works from a board window), and filing it under `AppModel.launchFailures` would put it in a
/// recents-adjacent list belonging to boards the registry knows — which this one, having never
/// existed, is not. So it is an `NSAlert`, continuing the modal conversation the user is already in
/// with the save panel, in the same sentence the banner would have used
/// (`BannerCenter.headline(for:)`) so the app has one vocabulary for a failed write rather than two.
struct TemplateChooserView: View {
@Environment(AppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
/// The chooser's rows — both tiers, in 09's order, re-read on presentation and on activation
/// (see the type's doc). `@State` rather than a computed property because discovery *loads every
/// template board*, and a computed one would re-run that on every SwiftUI rebuild.
@State private var rows: [TemplateRow] = []
@State private var selection: TemplateRow.ID?
/// Which tile holds the keyboard.
///
/// **The grid's Full Keyboard Access wiring** (10-accessibility.md ▸ Full Keyboard Access: "every
/// control — … template chooser — is Tab-reachable"). Before this the tiles were bare
/// `onTapGesture`s: the chooser could be Tabbed as far as Cancel and Choose, but the *choice*
/// itself was pointer-only, so a keyboard user could only ever create the default template.
///
/// Focus and selection are deliberately the same thing here, unlike the style editor's grids
/// where "selection is never implied by focus" because a well writes to disk. A tile writes
/// nothing — it names what Choose will act on — so moving focus onto one *is* choosing it, which
/// is how every list and icon grid on the system behaves.
@FocusState private var focusedRow: TemplateRow.ID?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
/// The selected row, defaulting to the first — which is Basic, the bundled tier's lowest order,
/// so the chooser always opens with something choosable in hand.
private var selected: TemplateRow? {
rows.first { $0.id == selection } ?? rows.first
}
// MARK: - Geometry
//
// Every figure the sheet lays out on, as a multiple of the body font — `BoardMetrics`' rule
// applied to a window rather than to the board (10-accessibility.md ▸ Text scaling & visual
// accommodations). At the standard 13pt body they reproduce the numbers the chooser has always
// drawn: a 620 × 480 sheet, a 20pt inset, and tiles at least 170 points across.
@MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
@MainActor static var windowWidth: CGFloat { BoardMetrics.em(47.7, bodyPointSize: pointSize) }
@MainActor static var windowHeight: CGFloat { BoardMetrics.em(37, bodyPointSize: pointSize) }
@MainActor static var inset: CGFloat { BoardMetrics.em(1.55, bodyPointSize: pointSize) }
@MainActor static var tileMinimumWidth: CGFloat { BoardMetrics.em(13, bodyPointSize: pointSize) }
/// The width the grid actually gets — the sheet minus its two insets. Used only by the arrow
/// handler, which needs a column count `.adaptive` never tells it.
@MainActor static var gridWidth: CGFloat { windowWidth - 2 * inset }
var body: some View {
VStack(spacing: 0) {
header
Divider()
grid
Divider()
footer
}
// Font-derived, like every other frame in the app (10-accessibility.md ▸ Text scaling: "no
// fixed point sizes"). This is a *fixed* sheet — the user cannot resize their way out of a
// clipped one — so a 620×480 literal would put the header's two lines and the footer's blurb
// outside the window at a large system text size.
.frame(width: Self.windowWidth, height: Self.windowHeight)
.task {
rescan()
// Returning to the foreground is when a folder dropped into the revealed store becomes
// this window's problem — `ClipboardStore`'s activation observer, in the shape a view can
// hold: the sequence ends with the task, which ends with the window.
for await _ in NotificationCenter.default.notifications(named: NSApplication.didBecomeActiveNotification) {
rescan()
}
}
}
/// Re-reads both stores, keeping the selection if the row it named is still there.
private func rescan() {
rows = TemplateEngine.chooserRows()
if let selection, !rows.contains(where: { $0.id == selection }) {
self.selection = nil
}
}
// MARK: Header
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("Choose a Template")
.font(.title3.weight(.semibold))
Text("Every template is an ordinary board — lanes and cards you can change afterwards.")
.font(.callout)
.foregroundStyle(.secondary)
}
Spacer(minLength: 16)
// 09's honesty affordance: the store is plain board folders, and this is where the user
// is shown that. It mints the folder on the way — see the type's doc.
Button {
revealUserStore()
} label: {
Label("My Templates", systemImage: "folder")
}
.help("Reveal your templates folder in the Finder. Any board folder you put there becomes a template.")
}
.padding(Self.inset)
}
// MARK: Grid
private var grid: some View {
ScrollView {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: Self.tileMinimumWidth), spacing: Self.inset)],
spacing: Self.inset
) {
ForEach(rows) { row in
TemplateCard(row: row, isSelected: row.id == selected?.id)
// A double click is how a chooser is answered without reaching for a
// button (welcome's list convention). One recogniser branching on
// `PointerClick.count`, never a second two-tap one — stacked, it delays
// the single click by the whole double-click interval; simultaneous, it
// still holds clicks on a view with no drag source (`PointerClick`). The
// first click of the pair selects the tile, which is also what aims
// `choose()` at the clicked row.
.onTapGesture {
if PointerClick.count > 1 { choose() } else { selection = row.id }
}
// **Tab-reachable, and a button to the accessibility tree** — the tile is
// the chooser's one act of choosing, so it has to be a control rather than a
// decorated rectangle that happens to answer clicks (10-accessibility.md ▸
// Full Keyboard Access).
.focusable()
.focused($focusedRow, equals: row.id)
.accessibilityAddTraits(row.id == selected?.id ? [.isButton, .isSelected] : [.isButton])
// Space picks the focused tile — the keyboard face of the single click above.
// Return is deliberately *not* handled here: it is the sheet's default action
// (Choose), and a tile that swallowed it would leave a keyboard user focused
// on their choice with no way to answer the chooser.
.onKeyPress(.space) {
selection = row.id
return .handled
}
}
}
.padding(Self.inset)
// The arrows walk the tiles — `StyleWellGrid`'s handler on the container, for its
// reason: a focused control does not consume arrow keys, so the press bubbles here and
// moving focus is all it does.
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
move(press.key)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(nsColor: .controlBackgroundColor))
// Focus *is* selection in this grid (see `focusedRow`), so the two are kept in step in one
// direction only: moving focus names the choice, and a pointer click that named a choice
// leaves focus alone rather than yanking it out from under the keyboard.
.onChange(of: focusedRow) { _, focused in
guard let focused else { return }
selection = focused
}
}
/// One step per press, clamped at the ends rather than wrapped — `StyleWellGrid.move`'s rule,
/// for its reason: a grid whose last row is short would wrap into a hole.
///
/// The vertical step is the grid's own column count, which `.adaptive` decides at layout time
/// and no one here can read. It is recomputed from the same two numbers the `GridItem` was built
/// from, so ↑/↓ land a row away rather than an arbitrary distance.
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
guard !rows.isEmpty else { return .ignored }
let columns = max(1, Int(Self.gridWidth / (Self.tileMinimumWidth + Self.inset)))
let delta: Int
switch key {
case .leftArrow: delta = -1
case .rightArrow: delta = 1
case .upArrow: delta = -columns
case .downArrow: delta = columns
default: return .ignored
}
let current = rows.firstIndex { $0.id == (focusedRow ?? selected?.id) } ?? 0
let next = min(max(0, current + delta), rows.count - 1)
focusedRow = rows[next].id
return .handled
}
// MARK: Footer
/// The blurb — or, for an unloadable row, **the loader's own sentence**: fail-fast's specifics,
/// unreworded, in the place the description would have been. It is the whole of what the user
/// needs to go and fix the file.
private var footer: some View {
HStack(alignment: .firstTextBaseline) {
if let unloadable = selected?.unloadable {
Label(BannerCenter.headline(for: unloadable.error), systemImage: "exclamationmark.triangle")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text(selected?.template?.blurb ?? "")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 16)
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Button("Choose") { choose() }
.keyboardShortcut(.defaultAction)
// An unloadable row "can't be instantiated or previewed" (09), which is this line.
.disabled(selected?.template == nil)
}
.padding(Self.inset)
}
// MARK: - Reveal
/// Creates the user store if it isn't there, then reveals it — 09's affordance, and one of the
/// store's two minters (`TemplateEngine.createUserStore`).
///
/// A store that cannot be created is logged and *still* revealed at its parent by
/// `activateFileViewerSelecting`, which is the honest failure: something is wrong with
/// the app-side store, and the user is standing where they can see it.
private func revealUserStore() {
let store = TemplateEngine.userStore
do {
try TemplateEngine.createUserStore(at: store)
} catch {
Self.logger.error("could not create the user template store: \(error.localizedDescription, privacy: .public)")
}
NSWorkspace.shared.activateFileViewerSelecting([store])
}
// MARK: - Choosing
/// Panel, instantiate, open — and only then dismiss, so a cancelled panel leaves the chooser
/// exactly as the user left it.
///
/// The copy runs in a detached task, `DuplicateBoardCommand`'s reasoning at a smaller scale: a
/// user template can be a real board with real attachments, and a main thread blocked inside a
/// tree copy is a frozen window. Detached rather than a child task so its cancellation is only
/// ever the one a Cancel affordance hands it, never something inherited.
private func choose() {
guard let template = selected?.template, let url = Self.chooseLocation(for: template) else { return }
let title = TemplateEngine.documentName(of: url)
Task { @MainActor in
let outcome = await Task.detached(priority: .userInitiated) {
() -> Result<URL, TemplateEngine.Failure> in
do throws(TemplateEngine.Failure) {
return .success(try TemplateEngine.instantiate(template: template, to: url, title: title))
} catch {
return .failure(error)
}
}.value
switch outcome {
case .success:
dismiss()
// The ordinary open path, so the new board joins recents, gets its bookmark, and
// closes welcome on the way in exactly like a board opened from a row.
appModel.openBoard(at: url)
case .failure(.cancelled):
// Nothing was created and nothing failed, so nothing is said — the duplicate rule.
Self.logger.notice("template instantiation cancelled — the partial board was removed")
case let .failure(.failed(error)):
Self.logger.error("template instantiation failed: \(error.description, privacy: .public)")
Self.present(error)
}
}
}
/// The save panel — where the board goes and what it is called.
///
/// **The suggestion is the template's own title** (`"Basic.kanban"`, `"Bug Tracker.kanban"`) —
/// 09 ▸ Instantiation: "seed the save panel's suggested name from the template title". Whatever
/// the user types instead becomes the new board's `title` as well as its folder name, so the two
/// start out matching (01-storage-format.md § Board naming). The package extension is visible and
/// editable, because an extension-less board folder is equally legal (§ Document packaging) and
/// deleting the suffix should therefore work rather than be silently undone.
///
/// A name that already exists gets the panel's own replace prompt; agreeing to it does not delete
/// anything (the panel never does), so the engine's refusal to clobber an existing board is what
/// the user sees — as an alert, naming the path. That is the honest outcome: this flow is a
/// *create*, and quietly replacing a board with an empty one is not a thing it should be able to
/// do.
private static func chooseLocation(for template: BoardTemplate) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = TemplateEngine.suggestedFileName(for: template)
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.allowsOtherFileTypes = true
panel.prompt = "Create"
panel.message = "Choose where to keep the new board."
guard panel.runModal() == .OK, let url = panel.url else { return nil }
return url
}
private static func present(_ error: BoardWriteError) {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = BannerCenter.headline(for: error)
alert.informativeText = error.path
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
// MARK: - Template card
/// One row in the grid: its preview, its name, and the selection ring — or, for a folder that did not
/// load, the same frame with an unloadable badge where the preview would be.
///
/// The two cases share a frame deliberately: an unloadable template is **the same kind of thing** as
/// the ones beside it, one edit away from working (09), so hiding it in a separate list would say the
/// opposite of what 09 means by "still listed".
private struct TemplateCard: View {
let row: TemplateRow
let isSelected: Bool
/// Increase Contrast, for the tile's frame below — 10-accessibility.md names both halves of it
/// ("strengthens borders and the selection indicator"), and this one shape is both
/// (`Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
private var cornerRadius: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }
var body: some View {
VStack(spacing: BoardMetrics.em(0.6, bodyPointSize: pointSize)) {
content
.frame(height: BoardMetrics.em(7.4, bodyPointSize: pointSize))
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(Color(nsColor: .textBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isSelected ? Color.accentColor : Color(nsColor: .separatorColor),
lineWidth: Accommodations.borderWidth(isSelected ? 3 : 1, contrast: contrast))
)
Label(row.name, systemImage: icon)
.font(.callout)
.labelStyle(.titleAndIcon)
.lineLimit(1)
}
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityLabel(row.name)
.accessibilityHint(hint)
}
@ViewBuilder
private var content: some View {
switch row {
case let .template(template):
TemplatePreview(template: template)
case .unloadable:
// No preview, because there is no board to preview — the badge says why the tile is
// empty rather than leaving it looking like a template with no lanes.
VStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle")
.font(.title2)
Text("Can't be read")
.font(.caption)
}
.foregroundStyle(.secondary)
}
}
private var icon: String {
switch row {
case let .template(template): template.icon
case .unloadable: "exclamationmark.triangle"
}
}
private var hint: String {
switch row {
case let .template(template): template.blurb
case let .unloadable(unloadable): BannerCenter.headline(for: unloadable.error)
}
}
}
/// The mini per-lane preview: one column per lane, a tinted title bar over the lane's **own cards**.
///
/// It renders **from the template's loaded `BoardModel`** (09-templates.md ▸ Why this format: "The
/// picker's mini per-lane preview renders from a real `BoardModel` via the normal loader"), and every
/// mark in it is read off that model rather than decorated in: the number of columns is the lane
/// count, each column's tint is the lane's own `iconColor` through `Palette`, and the number of card
/// shapes is how many cards the lane actually holds — templates "may contain starter cards", so a
/// board with a "How this board works" card in its first lane looks different here from one without.
///
/// Deliberately **textless**: legible lane names are not available at this size, and the point of the
/// preview is the shape of the board. A lane with no cards draws an empty column, which is the honest
/// picture of an empty lane rather than a decorative one.
private struct TemplatePreview: View {
let template: BoardTemplate
/// How many lanes and cards a tile can show before the marks stop being distinguishable. A
/// template with more of either is truncated rather than shrunk to threads — the preview is an
/// impression, and every bundled template fits inside both.
private static let laneLimit = 6
private static let cardLimit = 4
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
var body: some View {
// Every mark is a fraction of the body font, like the tile that holds it — the preview is a
// miniature of the board, and the board scales (10-accessibility.md's full-relative-scaling
// rule). A fixed 5pt lane band inside a tile that grew would read as a hairline.
HStack(alignment: .top, spacing: BoardMetrics.em(0.4, bodyPointSize: pointSize)) {
ForEach(template.lanes.prefix(Self.laneLimit)) { lane in
VStack(spacing: BoardMetrics.em(0.3, bodyPointSize: pointSize)) {
RoundedRectangle(cornerRadius: BoardMetrics.em(0.15, bodyPointSize: pointSize))
.fill(Self.tint(of: lane))
.frame(height: BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize))
ForEach(0 ..< min(lane.cards.count, Self.cardLimit), id: \.self) { _ in
RoundedRectangle(cornerRadius: BoardMetrics.em(0.25, bodyPointSize: pointSize))
.fill(.quaternary)
.frame(height: BoardMetrics.em(0.9, bodyPointSize: pointSize))
}
Spacer(minLength: 0)
}
}
}
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.accessibilityHidden(true)
}
/// The lane's palette tint, falling back to the accent colour — `Palette` is the one place a
/// colour name is resolved, and a lane that names none looks like the chrome default here exactly
/// as it does on a board.
private static func tint(of lane: Lane) -> Color {
(Palette.color(for: lane.iconColor) ?? .accentColor).opacity(0.65)
}
}