import AppKit import SwiftUI import os /// The template chooser — File ▸ New Board… (⌥⌘N), 09-templates.md's picker. /// /// ### Pages' shape, and the templates are real board folders now /// /// 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. /// // 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. /// /// ### 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. /// /// ### 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 /// 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] = [] @State private var selection: BoardTemplate.ID? private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates") private var selected: BoardTemplate? { templates.first { $0.id == selection } ?? templates.first } var body: some View { VStack(spacing: 0) { header Divider() grid Divider() footer } .frame(width: 620, height: 460) .onAppear { guard templates.isEmpty else { return } templates = TemplateEngine.bundledTemplates() } } // 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) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) } // MARK: Grid 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 } // 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] : []) } } .padding(20) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color(nsColor: .controlBackgroundColor)) } // MARK: Footer private var footer: some View { HStack(alignment: .firstTextBaseline) { Text(selected?.blurb ?? "") .font(.callout) .foregroundStyle(.secondary) .lineLimit(2) Spacer(minLength: 16) Button("Cancel", role: .cancel) { dismiss() } .keyboardShortcut(.cancelAction) Button("Choose") { choose() } .keyboardShortcut(.defaultAction) .disabled(selected == nil) } .padding(20) } // 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, 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 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 template in the grid: its mini per-lane preview, its name, and the selection ring. private struct TemplateCard: View { let template: BoardTemplate let isSelected: Bool var body: some View { VStack(spacing: 8) { TemplatePreview(template: template) .frame(height: 96) .background(RoundedRectangle(cornerRadius: 8).fill(Color(nsColor: .textBackgroundColor))) .overlay( RoundedRectangle(cornerRadius: 8) .strokeBorder(isSelected ? Color.accentColor : Color(nsColor: .separatorColor), lineWidth: isSelected ? 3 : 1) ) Label(template.name, systemImage: template.icon) .font(.callout) .labelStyle(.titleAndIcon) } .contentShape(Rectangle()) .accessibilityElement(children: .combine) .accessibilityLabel(template.name) .accessibilityHint(template.blurb) } } /// The mini per-lane preview: one column per lane, each a title bar over a couple of card shapes. /// /// 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. /// // 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. private struct TemplatePreview: View { let template: BoardTemplate var body: some View { HStack(alignment: .top, spacing: 6) { ForEach(Array(template.lanes.enumerated()), id: \.element.id) { index, _ in VStack(spacing: 4) { RoundedRectangle(cornerRadius: 2) .fill(Color.accentColor.opacity(0.65)) .frame(height: 5) // A descending number of cards, so the preview reads as work in flight rather // than as three identical columns. ForEach(0..