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 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) } }