import AppKit import SwiftUI /// **The one style editor** — "a background palette grid and a curated symbol grid — presented from /// three anchors" (03-board-ui.md § Styling ▸ Controls), plus the two small surfaces that stand /// beside it: the quick-style recents row the context menus carry, and the funnel every anchor's /// writes pass through. /// /// This file is deliberately anchor-agnostic. It knows a `BoardStore`, a `StyleTarget` and the app's /// recents, and nothing at all about popovers, card-window sidebars or the board popover — which is /// what lets "one component, one behavior, three anchors" be a fact about the code rather than a /// promise. The Style… popover's *lifecycle* lives elsewhere for the same reason: it is a reload /// rule, and it belongs with the other reload rules (`StyleEditorSession`, `TransientBoardState`). /// /// The one thing here that *names* an anchor is `StyleEditorLayout`, and it names only geometry: a /// popover is a window this app sizes and a sidebar section is a column the window sizes, so the two /// cannot share a frame. Nothing behavioral hangs off it — see its own doc comment. // MARK: - The write funnel /// Where every style application from every anchor goes: the store's write, and the recents list /// that the write feeds. /// /// **It exists so "updated on every background application from any anchor" is structural.** Two /// surfaces apply backgrounds — the editor's wells and the quick-style row — and the recents list is /// app-wide state a board store has no business knowing about (02-architecture.md § Per-board app /// state), so neither of them may be trusted to remember it and neither may be given the job alone. /// /// **The None well never records.** It is a *removal* — `background` leaves the file — so there is no /// colour to remember; only `.set` reaches `StyleRecents.record`. @MainActor enum StyleCommand { static func apply( background: StyleChange = .keep, icon: StyleChange = .keep, to target: StyleTarget, in store: BoardStore, recents: StyleRecents ) { store.applyStyle(to: target, background: background, icon: icon) if case let .set(value) = background { recents.record(value) } } } // MARK: - The curated symbol set /// The symbol grid's contents — "a hand-picked set (roughly five dozen kanban-relevant SF Symbols)" /// (03-board-ui.md § Styling ▸ Controls). /// /// The pathfinder's two quick-pick lists (card markers, container-like stages) and its browser /// fallback set are the seed, widened to one grid's worth: the rewrite has no full-catalog browser /// to fall back to — "no full-browser escape hatch in-app; the raw file is the escape hatch" — so /// this set has to stand alone for the common case, and it is grouped by what a board item *is* /// rather than alphabetically so scanning it works. /// /// **Filtered through `ItemSymbol.exists` at read time**, for the same reason the renderer is /// lenient: symbol inventories grow per macOS release, and a name this OS does not know would draw /// an empty well. A curated list is a convenience, never a claim about the running system. enum CuratedSymbols { /// Every well in the grid, in order. Deliberately a stored constant rather than a computed /// property: the list is the design decision, and `available` is the only thing the OS gets a /// say in. static let all: [String] = [ // Status and flow "flag", "flag.checkered", "star", "bolt", "checkmark.circle", "checkmark.seal", "xmark.circle", "exclamationmark.triangle", "questionmark.circle", "circle", "pause.circle", "play.circle", // Time "hourglass", "clock", "alarm", "calendar", "timer", // Work and craft "hammer", "wrench.and.screwdriver", "gearshape", "ant", "lightbulb", "paintbrush", "pencil", // Documents "doc.text", "doc.on.doc", "note.text", "list.bullet", "list.bullet.rectangle", "checklist", "book", "bookmark", // Containers and stages "tray", "tray.full", "folder", "archivebox", "shippingbox", "square.stack", // People and communication "person", "person.2", "bubble.left", "bubble.left.and.bubble.right", "envelope", "megaphone", // Data and systems "chart.bar", "chart.pie", "chart.line.uptrend.xyaxis", "terminal", "network", // Markers "tag", "paperclip", "link", "pin", "target", "flame", "leaf", "sparkles", "heart", // Motion "arrow.triangle.branch", "arrow.triangle.2.circlepath", "arrow.up.arrow.down", // Other "lock", "key", "trash", ] /// The set this Mac can actually draw. static var available: [String] { all.filter(ItemSymbol.exists) } } // MARK: - The anchor's chrome /// Everything about the editor that is the **anchor's** business rather than the editor's: how wide /// it is, what padding it brings, how many wells fall in a row, and whether its symbol grid scrolls. /// /// **It exists so "one component, one behavior, another anchor" survives an anchor that is not a /// popover** (05-card-window.md ▸ Style: the card sidebar embeds this same editor). A popover is a /// window the app sizes; a sidebar section is a column the window sizes — and the 268-point frame /// that makes the first one narrow enough to sit beside a card would overflow the second by 70 /// points. Nothing about *behavior* is in here: every well, every write, the batch display and the /// keyboard grammar are the editor's, identical at every anchor. Only the geometry moves. struct StyleEditorLayout: Equatable { /// One well's side, and the gap between two — the numbers the grids are laid out on, named once /// so the fit rule below and the wells themselves cannot drift apart. static let wellSide: CGFloat = 20 static let wellSpacing: CGFloat = 6 /// A fixed width, or `nil` to take whatever the anchor proposes. var width: CGFloat? /// The editor's own inset. Zero where the anchor already insets its column. var padding: CGFloat var backgroundColumns: Int var symbolColumns: Int /// How tall the symbol grid may grow before it scrolls inside itself, or `nil` for "never" — /// the grid then draws whole and the anchor scrolls it. var symbolGridMaximumHeight: CGFloat? /// The Style… popover and the board popover's styling area: a fixed frame, its own padding, and /// a symbol grid that scrolls within it. /// /// Thirteen background wells (None + the twelve) fall as 7 + 6, which keeps the popover narrow /// enough to sit beside a card without covering the lane it came from; the symbol grid's cap is /// eight rows or so — enough that it reads as a set rather than as a strip, short enough that the /// popover fits beside a card on a laptop screen. static let popover = StyleEditorLayout( width: 268, padding: 14, backgroundColumns: 7, symbolColumns: 8, symbolGridMaximumHeight: 168 ) /// The card window's sidebar section (05-card-window.md ▸ Style). /// /// - **No width and no padding of its own**: the sidebar's width is `CardWindowMetrics`' one /// decision and its gutter is already applied to the whole section stack, so an editor with an /// opinion here would either overflow the column or inset twice. /// - **As many wells per row as the column holds**, rather than the popover's 7 and 8 — the /// sidebar is narrower than the popover at every text size, and a grid wider than its column is /// a grid with wells the pointer cannot reach. /// - **The symbol grid does not scroll.** The sidebar is already a scroll view, and a scroll view /// inside a scroll view is a scroll view that fights (`CardWindowView`'s rule, for its reason). static func sidebar(contentWidth: CGFloat) -> StyleEditorLayout { let columns = columns(fitting: contentWidth) return StyleEditorLayout( width: nil, padding: 0, backgroundColumns: columns, symbolColumns: columns, symbolGridMaximumHeight: nil ) } /// How many wells fit across `width` — `n` wells and `n - 1` gaps, floored, and never less than /// one. Pure, and the whole of "the grid never overflows the column it was given". static func columns(fitting width: CGFloat) -> Int { max(1, Int((width + wellSpacing) / (wellSide + wellSpacing))) } } // MARK: - The editor /// The style editor: a background section and a symbol section, each a leading "no value" well /// followed by its grid, with the target set's current value stated beside the section title. /// /// ### What it shows for a batch /// /// Per dimension, `StyleFieldState`: every target agreeing shows that well selected, a disagreement /// shows nothing selected and reads "—" ("Mixed" to VoiceOver — 10-accessibility.md's /// never-colour-alone rule), and an off-palette value — a hand-written hex, an uncurated symbol — /// states itself verbatim beside the title, outside the grids, where "choosing any well replaces /// it". /// /// ### Keyboard /// /// "Inside the editor the grids are arrow-navigable and every well Tab-reachable" (§ Controls, /// 10-accessibility.md): every well is a focusable button, and each grid moves focus by one on /// ←/→ and by a row on ↑/↓. struct StyleEditorView: View { let store: BoardStore let recents: StyleRecents let target: StyleTarget /// The anchor's geometry, and nothing else (`StyleEditorLayout`). Defaulted to the popover's, so /// the two anchors that were here first say nothing about it. var layout: StyleEditorLayout = .popover var body: some View { let subjects = store.styleSubjects(of: target) let background = StyleFieldState.resolve(subjects.map(\.background)) let icon = StyleFieldState.resolve(subjects.map(\.icon)) VStack(alignment: .leading, spacing: 14) { targetCaption(count: subjects.count) backgroundSection(background) Divider() symbolSection(icon) } .padding(layout.padding) .frame(width: layout.width) // The read-only lock and the focused-editor rule disable every mutating surface, not only // the menu items (02-architecture.md § The lock's scope) — an editor whose wells would be // refused should not look available. The popover stays *open*: the lock is a condition the // banner is already explaining, not a reason to yank a surface out from under the pointer. .disabled(!store.acceptsBoardMutations) } /// Who is being styled — one quiet line, because a batch gesture with no statement of its scope /// is the one place this editor could silently do more than the user meant. private func targetCaption(count: Int) -> some View { Text(caption(count: count)) .font(.caption) .foregroundStyle(.secondary) } private func caption(count: Int) -> String { switch store.styleLevel(of: target) { case .board: "Board" case .lane: count == 1 ? "Lane" : "\(count) lanes" case .card: count == 1 ? "Card" : "\(count) cards" } } // MARK: - Background /// The twelve palette wells and their leading None (03-board-ui.md § Styling ▸ Controls: /// "palette-only in-app … plus a leading **None** well that removes the `background` key"). private func backgroundSection(_ state: StyleFieldState) -> some View { VStack(alignment: .leading, spacing: 8) { sectionHeader("Background", current: backgroundCurrent(state)) StyleWellGrid( wells: backgroundWells(state), columns: layout.backgroundColumns, apply: { change in StyleCommand.apply(background: change, to: target, in: store, recents: recents) } ) } } private func backgroundWells(_ state: StyleFieldState) -> [StyleWell] { var wells = [StyleWell(id: 0, face: .noValue, label: "None", change: .remove, isSelected: state == .unset)] for (index, color) in Palette.backgrounds.enumerated() { wells.append(StyleWell( id: index + 1, face: .color(color.name), label: color.name, change: .set(color.name), isSelected: state == .uniform(color.name) )) } return wells } /// What the background dimension currently reads — including the verbatim off-palette case, which /// is exactly why this is a chip beside the title and not a highlighted well. private func backgroundCurrent(_ state: StyleFieldState) -> CurrentValue { switch state { case .unset: CurrentValue(face: .noValue, text: "None") case .mixed: CurrentValue(face: nil, text: "—", spoken: "Mixed") case let .uniform(value): CurrentValue(face: .color(value), text: value) } } // MARK: - Symbol /// The curated grid and its leading default well — "its leading well is the level's default /// symbol and removes the `icon` key" (§ Controls). private func symbolSection(_ state: StyleFieldState) -> some View { let level = store.styleLevel(of: target) let fallback = ItemSymbol.default(for: level) return VStack(alignment: .leading, spacing: 8) { sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback)) symbolGrid(state, fallback: fallback) } } /// The curated grid, scrolling within its own cap or drawn whole — the anchor's call /// (`StyleEditorLayout.symbolGridMaximumHeight`), and the one shape difference between the /// popover and the card sidebar. @ViewBuilder private func symbolGrid(_ state: StyleFieldState, fallback: String) -> some View { let grid = StyleWellGrid( wells: symbolWells(state, fallback: fallback), columns: layout.symbolColumns, apply: { change in StyleCommand.apply(icon: change, to: target, in: store, recents: recents) } ) if let maximumHeight = layout.symbolGridMaximumHeight { ScrollView(.vertical) { grid } .frame(maxHeight: maximumHeight) } else { grid } } private func symbolWells(_ state: StyleFieldState, fallback: String) -> [StyleWell] { var wells = [StyleWell( id: 0, face: .defaultSymbol(fallback), label: "Default (\(fallback))", change: .remove, isSelected: state == .unset )] for (index, name) in CuratedSymbols.available.enumerated() { wells.append(StyleWell( id: index + 1, face: .symbol(name), label: name, change: .set(name), isSelected: state == .uniform(name) )) } return wells } private func symbolCurrent(_ state: StyleFieldState, fallback: String) -> CurrentValue { switch state { case .unset: CurrentValue(face: .defaultSymbol(fallback), text: "Default") case .mixed: CurrentValue(face: nil, text: "—", spoken: "Mixed") case let .uniform(value): CurrentValue(face: .symbol(value), text: value) } } // MARK: - Section chrome private func sectionHeader(_ title: String, current: CurrentValue) -> some View { HStack(spacing: 6) { Text(title) .font(.subheadline.weight(.semibold)) Spacer(minLength: 8) if let face = current.face { StyleWellFace(face: face, size: 14) } Text(current.text) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } .accessibilityElement(children: .ignore) .accessibilityLabel("\(title), \(current.spoken ?? current.text)") } } // MARK: - Current value /// The current-value chip beside a section title: the one place an off-palette value is stated /// ("labeled verbatim, outside the grids"), and the one place a mixed batch reads "—". private struct CurrentValue { let face: StyleWellFace.Face? let text: String /// What VoiceOver says when the written text would not do — "Mixed" for the em dash, which is a /// glyph rather than a word (10-accessibility.md: a batch's mixed state "reads as 'mixed', never /// conveyed by highlight alone"). var spoken: String? init(face: StyleWellFace.Face?, text: String, spoken: String? = nil) { self.face = face self.text = text self.spoken = spoken } } // MARK: - Wells /// One well: what it draws, what it is called, and what clicking it asks of the frontmatter key. private struct StyleWell: Identifiable { let id: Int let face: StyleWellFace.Face let label: String let change: StyleChange let isSelected: Bool } /// A well's face — a colour, a symbol, or one of the two "no value" leading wells. private struct StyleWellFace: View { enum Face: Equatable { /// The background grid's None well: a slashed empty swatch, Finder's own vocabulary for /// "there isn't one". case noValue /// A palette name or a hand-written hex. An unresolvable value draws like `noValue` — the /// renderer's lenient rule, which is what makes an off-palette chip honest about a value the /// app cannot read. case color(String) case symbol(String) /// The symbol grid's leading well: the level's default, drawn quieter than a chosen one so /// "no symbol set" and "this symbol set" do not look alike. case defaultSymbol(String) } let face: Face var size: CGFloat = StyleEditorLayout.wellSide var body: some View { switch face { case .noValue: swatch(nil) case let .color(value): swatch(Palette.color(named: value)) case let .symbol(name): glyph(name, tint: AnyShapeStyle(.primary)) case let .defaultSymbol(name): glyph(name, tint: AnyShapeStyle(.secondary)) } } /// A colour well. **Always stroked**: `chalk` is `#FFFFFF` and an unbordered white swatch is an /// invisible control on a light popover (10-accessibility.md's contrast stance turned on the /// app's own chrome). A `nil` colour adds the diagonal strike that means "none". private func swatch(_ color: Color?) -> some View { RoundedRectangle(cornerRadius: 4) .fill(color ?? Color(nsColor: .textBackgroundColor)) .overlay { if color == nil { NoValueStrike().stroke(.secondary, lineWidth: 1) } } .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(.separator, lineWidth: 1)) .frame(width: size, height: size) } private func glyph(_ name: String, tint: AnyShapeStyle) -> some View { Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed") .imageScale(.medium) .foregroundStyle(tint) .frame(width: size, height: size) } } /// The corner-to-corner slash on the None well — the pathfinder's swatch vocabulary, kept because it /// is also the system's (an empty colour well slashes in Finder's own tag editor). private struct NoValueStrike: Shape { func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX + 3, y: rect.maxY - 3)) path.addLine(to: CGPoint(x: rect.maxX - 3, y: rect.minY + 3)) return path } } /// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid (10-accessibility.md ▸ Style /// editor). /// /// Focus is the grid's own state rather than the editor's, because the two grids are independently /// navigable and Tab is what crosses between them — which is exactly what the accessibility doc asks /// for ("the grids are arrow-navigable and every well Tab-reachable"). The arrow handler sits on the /// container: a focused `Button` does not consume arrow keys, so the press bubbles here, and moving /// focus is all it does — **selection is never implied by focus**, since a well's job is to write to /// disk and a stray arrow key must not restyle a board. private struct StyleWellGrid: View { let wells: [StyleWell] let columns: Int let apply: (StyleChange) -> Void @FocusState private var focused: Int? var body: some View { LazyVGrid( columns: Array( repeating: GridItem(.flexible(minimum: StyleEditorLayout.wellSide), spacing: StyleEditorLayout.wellSpacing), count: columns ), spacing: StyleEditorLayout.wellSpacing ) { ForEach(wells) { well in Button { apply(well.change) } label: { StyleWellFace(face: well.face) .overlay(selectionRing(well.isSelected)) .contentShape(Rectangle()) } .buttonStyle(.plain) .focusable() .focused($focused, equals: well.id) .help(well.label) .accessibilityLabel(well.label) .accessibilityAddTraits(well.isSelected ? [.isSelected] : []) } } .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in move(press.key) } } private func selectionRing(_ isSelected: Bool) -> some View { RoundedRectangle(cornerRadius: 5) .strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 2) .padding(-2) } /// One step per press, clamped at the ends rather than wrapped: a grid whose last row is short /// would wrap into a hole, and Finder's own icon grids clamp too. private func move(_ key: KeyEquivalent) -> KeyPress.Result { 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 = focused ?? 0 let next = min(max(0, current + delta), wells.count - 1) focused = next return .handled } } // MARK: - Context-menu surfaces /// The two style entries every context menu carries — Style… and the quick-style recents row /// (11-command-nexus.md ▸ Context menus; 03-board-ui.md § Styling ▸ Controls). /// /// One view for both menus because the entries are identical on a card and on a lane: only the /// *target* differs, and that is the caller's to compute (the clicked item, or the selection it /// belongs to). struct StyleMenuItems: View { let store: BoardStore let recents: StyleRecents let target: StyleTarget var body: some View { Button("Style…") { store.transient.beginStyleEditor(for: target) } .disabled(!store.acceptsBoardMutations) QuickStyleRow(store: store, recents: recents, target: target) } } /// The quick-style row: "one compact row of recently used backgrounds … one-click recolor for the /// common case; the pathfinder's second full-palette tier is gone" (03-board-ui.md § Styling ▸ /// Controls). /// /// A `.palette`-styled `Picker` is what macOS renders as a horizontal swatch strip inside a menu — /// the pathfinder's finding, and the only shape that puts colours in a menu row at all. AppKit draws /// a menu item from an image and a title, so the dots are `NSImage`s (`PaletteSwatch`) rather than /// SwiftUI shapes. /// /// **Absent until it has something to offer.** A brand-new install has no recents, and an empty /// picker in a context menu is a row that looks broken. struct QuickStyleRow: View { let store: BoardStore let recents: StyleRecents let target: StyleTarget /// A sentinel for "the current value is not one of these", so a mixed batch — or a background /// that has aged out of the recents — leaves the row unchecked rather than checking the wrong /// dot. It is never a rendered option, so it can never be picked. private enum Choice: Hashable { case value(String) case other } var body: some View { if !recents.backgrounds.isEmpty { Picker("Recent Colors", selection: selection) { ForEach(recents.backgrounds, id: \.self) { name in Label { Text(name) } icon: { Image(nsImage: PaletteSwatch.circleImage(for: name)) } .tag(Choice.value(name)) } } .pickerStyle(.palette) .disabled(!store.acceptsBoardMutations) } } private var selection: Binding { Binding( get: { let state = StyleFieldState.resolve(store.styleSubjects(of: target).map(\.background)) guard case let .uniform(value) = state, recents.backgrounds.contains(value) else { return .other } return .value(value) }, set: { picked in guard case let .value(name) = picked else { return } StyleCommand.apply(background: .set(name), to: target, in: store, recents: recents) } ) } } // MARK: - Presentation /// The Style… popover's content: the editor, aimed at **the session's own target set**. /// /// Reading the target from the session rather than re-deriving it from the selection is what makes /// the settled lifecycle visible: the popover was aimed once, at what the gesture named, and from /// then on it follows *that* set as members vanish — a selection change behind an open popover must /// not silently re-aim it, and a right-click on an unselected card must keep styling that card. struct StyleEditorPopover: View { let store: BoardStore let recents: StyleRecents var body: some View { // Empty for the frame between a session ending and the popover's own dismissal landing — // the binding is already `false`, so this is a formality rather than a state. if let session = store.transient.styleEditor { StyleEditorView(store: store, recents: recents, target: session.target) } } } /// Whether *this* anchor is the one showing the open Style… popover. /// /// Every candidate surface — a card face, a lane header, the strip itself — binds its popover /// through this, and `StyleEditorSession.presentationAnchor(in:)` answers for exactly one of them. /// So the popover follows its target set across reloads (an anchor that vanishes hands it to the next /// live target) and only an *emptied* set takes it down, which is the settled lifecycle. /// /// The setter is narrowed to this anchor's own dismissal: a session that has moved to another anchor /// must not be discarded by the surface it just left. @MainActor func styleEditorPresentation(_ store: BoardStore, anchor: ItemID?) -> Binding { Binding( // Spelled with an explicit `guard let` rather than optional chaining: `nil == nil` is // `true`, so a chained comparison would tell the board strip (whose anchor *is* `nil`) to // present a popover nobody opened. get: { guard let session = store.transient.styleEditor else { return false } return session.presentationAnchor(in: store.snapshot) == anchor }, set: { presented in guard !presented, let session = store.transient.styleEditor, session.presentationAnchor(in: store.snapshot) == anchor else { return } store.transient.discardStyleEditor() } ) }