Build the template chooser and Save as Template
The chooser completes its three tiers: bundled by template order, then keyed user templates, then keyless boards by display name — and a malformed user template still lists, by folder name with the loader's own sentence on the row, never failing its neighbours. The store is re-scanned on every presentation and on app activation, the Reveal round trip made honest without watching a folder 09 deliberately leaves unwatched; Reveal lives in the chooser's header and mints the store on first press. Save as Template repeats Duplicate's sequence — progress row with Cancel, flush, detached cancellable copy — through the engine: mint the store, read the next user order before the copy can count itself, Finder-ladder the name, copy excluding .git and .trash/, then stamp the whole template: mapping on the landed copy through updateIndex, with no bracket because the copy lives outside every watched board. Folder attributes deliberately don't carry — the one lock the command stays live under is the read-only-DMG one, and carrying its mode bits would mint a read-only template in the user's own store; the command gates instead on the real hazard, unsaved card content. A signpost names the template only when the ladder renamed it. One name ladder now serves Duplicate and the store. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -4,21 +4,40 @@ import os
|
||||
|
||||
/// The template chooser — File ▸ New Board… (⌥⌘N), 09-templates.md's picker.
|
||||
///
|
||||
/// ### Pages' shape, and the templates are real board folders now
|
||||
/// ### 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
|
||||
/// 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.bundledTemplates()` — the app bundle's `Templates/` folder, 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.
|
||||
/// `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.
|
||||
///
|
||||
// m9-templates: the full chooser is its own card. What is still missing here is the *user* tier
|
||||
// (`TemplateEngine.userStore`, listed after the bundled ones), the unloadable-template row, Reveal
|
||||
// in Finder, and the in-progress row with Cancel that copy-shaped work is owed (02-architecture.md
|
||||
// § The banner surface) — this window has no banner surface to host one yet. The engine already
|
||||
// takes the cancellation seam (`TemplateEngine.instantiate(…, isCancelled:)`); this view runs the
|
||||
// copy off the main actor so that row has something to spin over when it arrives.
|
||||
/// **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 Application Support 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
|
||||
///
|
||||
@@ -28,6 +47,13 @@ import os
|
||||
/// 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
|
||||
@@ -43,17 +69,19 @@ struct TemplateChooserView: View {
|
||||
@Environment(AppModel.self) private var appModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Discovered when the window appears, and not again: the bundle's `Templates/` folder cannot
|
||||
/// change under a running app, and discovery *loads every template board* — a default-value
|
||||
/// initializer would re-run it each time SwiftUI rebuilt this struct.
|
||||
@State private var templates: [BoardTemplate] = []
|
||||
/// 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: BoardTemplate.ID?
|
||||
@State private var selection: TemplateRow.ID?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
|
||||
|
||||
private var selected: BoardTemplate? {
|
||||
templates.first { $0.id == selection } ?? templates.first
|
||||
/// 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
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -64,24 +92,49 @@ struct TemplateChooserView: View {
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 620, height: 460)
|
||||
.onAppear {
|
||||
guard templates.isEmpty else { return }
|
||||
templates = TemplateEngine.bundledTemplates()
|
||||
.frame(width: 620, height: 480)
|
||||
.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 {
|
||||
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)
|
||||
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.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
@@ -90,13 +143,13 @@ struct TemplateChooserView: View {
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 20)], spacing: 20) {
|
||||
ForEach(templates) { template in
|
||||
TemplateCard(template: template, isSelected: template.id == selected?.id)
|
||||
.onTapGesture { selection = template.id }
|
||||
ForEach(rows) { row in
|
||||
TemplateCard(row: row, isSelected: row.id == selected?.id)
|
||||
.onTapGesture { selection = row.id }
|
||||
// The list convention welcome's recents use, for the same reason: a
|
||||
// double click is how a chooser is answered without reaching for a button.
|
||||
.onTapGesture(count: 2) { choose() }
|
||||
.accessibilityAddTraits(template.id == selected?.id ? [.isSelected] : [])
|
||||
.accessibilityAddTraits(row.id == selected?.id ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
@@ -107,12 +160,22 @@ struct TemplateChooserView: View {
|
||||
|
||||
// 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) {
|
||||
Text(selected?.blurb ?? "")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
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)
|
||||
|
||||
@@ -121,11 +184,30 @@ struct TemplateChooserView: View {
|
||||
|
||||
Button("Choose") { choose() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(selected == nil)
|
||||
// An unloadable row "can't be instantiated or previewed" (09), which is this line.
|
||||
.disabled(selected?.template == nil)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
// 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
|
||||
/// Application Support, 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
|
||||
@@ -136,7 +218,7 @@ struct TemplateChooserView: View {
|
||||
/// 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, let url = Self.chooseLocation(for: template) else { return }
|
||||
guard let template = selected?.template, let url = Self.chooseLocation(for: template) else { return }
|
||||
let title = TemplateEngine.documentName(of: url)
|
||||
|
||||
Task { @MainActor in
|
||||
@@ -204,16 +286,22 @@ struct TemplateChooserView: View {
|
||||
|
||||
// MARK: - Template card
|
||||
|
||||
/// One template in the grid: its mini per-lane preview, its name, and the selection ring.
|
||||
/// 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 template: BoardTemplate
|
||||
let row: TemplateRow
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
TemplatePreview(template: template)
|
||||
content
|
||||
.frame(height: 96)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color(nsColor: .textBackgroundColor)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
@@ -221,43 +309,83 @@ private struct TemplateCard: View {
|
||||
lineWidth: isSelected ? 3 : 1)
|
||||
)
|
||||
|
||||
Label(template.name, systemImage: template.icon)
|
||||
Label(row.name, systemImage: icon)
|
||||
.font(.callout)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(template.name)
|
||||
.accessibilityHint(template.blurb)
|
||||
.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, each a title bar over a couple of card shapes.
|
||||
/// The mini per-lane preview: one column per lane, a tinted title bar over the lane's **own cards**.
|
||||
///
|
||||
/// Deliberately abstract — no text, because the point is the *shape* of the board and legible lane
|
||||
/// names at this size are not available. 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"), taking the lane count and each lane's identity from it.
|
||||
/// 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.
|
||||
///
|
||||
// m9-templates: the card shapes are still decoration — a lane's *real* starter cards (templates
|
||||
// "may contain starter cards") should be what the column draws, once the chooser card gets to it.
|
||||
/// 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
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
ForEach(Array(template.lanes.enumerated()), id: \.element.id) { index, _ in
|
||||
HStack(alignment: .top, spacing: 5) {
|
||||
ForEach(template.lanes.prefix(Self.laneLimit)) { lane in
|
||||
VStack(spacing: 4) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.accentColor.opacity(0.65))
|
||||
.fill(Self.tint(of: lane))
|
||||
.frame(height: 5)
|
||||
// A descending number of cards, so the preview reads as work in flight rather
|
||||
// than as three identical columns.
|
||||
ForEach(0..<max(1, 3 - index), id: \.self) { _ in
|
||||
ForEach(0 ..< min(lane.cards.count, Self.cardLimit), id: \.self) { _ in
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(.quaternary)
|
||||
.frame(height: 14)
|
||||
.frame(height: 12)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -266,4 +394,11 @@ private struct TemplatePreview: View {
|
||||
.padding(10)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user