import AppKit import SwiftUI import os /// The template chooser — File ▸ New Board… (⌥⌘N), 09-templates.md's picker. /// /// ### Pages' shape, one card in it /// /// 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 holds exactly one card /// today because exactly one template exists (`BoardTemplate`); everything about the layout is /// already the plural case, so the m9 inventory drops in without the surface changing shape. /// /// ### 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 @State private var selection: BoardTemplate.ID = BoardTemplate.basic.id private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates") private var selected: BoardTemplate? { BoardTemplate.all.first { $0.id == selection } } var body: some View { VStack(spacing: 0) { header Divider() grid Divider() footer } .frame(width: 620, height: 460) } // 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(BoardTemplate.all) { template in TemplateCard(template: template, isSelected: template.id == selection) .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 == selection ? [.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. private func choose() { guard let template = selected, let url = Self.chooseLocation(for: template) else { return } do { try template.instantiate(at: url) } catch { Self.logger.error("template instantiation failed: \(error.description, privacy: .public)") Self.present(error) return } 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) } /// The save panel — where the board goes and what it is called. /// /// `"Untitled.kanban"` is the suggestion; the package extension is visible and editable, because /// an extension-less board folder is equally legal (01-storage-format.md § Document packaging) /// and deleting the suffix should therefore work rather than be silently undone. /// // m9-templates: 09 ▸ Instantiation seeds this name from the template's own title once templates // have titles of their own ("Basic.kanban", "Bug Tracker.kanban"). With one stub template a // suggestion of "Basic" would name the *template*, not the user's board, which is worse than // Untitled. /// /// A name that already exists gets the panel's own replace prompt; agreeing to it does not delete /// anything (the panel never does), so `BoardWriter.createBoard`'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 = "Untitled.kanban" 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 `laneTitles` only for the count and the /// stable identity of each column. private struct TemplatePreview: View { let template: BoardTemplate var body: some View { HStack(alignment: .top, spacing: 6) { ForEach(Array(template.laneTitles.enumerated()), id: \.offset) { 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..