The sidebar completes: the shared style editor gains a second anchor — StyleEditorLayout carries the geometry (the popover keeps its settled 268/14/7/8 untouched as the default; the sidebar packs columns to its width with no inner scroller) while every well, the batch display, the arrow grammar, and the one applyStyle bracket stay the shared component's. The card anchor is fixed, not tracking: the target is this card, and the fate walk retires the window when the card goes. Details renders every unknown frontmatter key read-only in file order — Card.document already carried them — showing the author's own bytes where the raw span is a value and the engine's rendering for block scalars and empties; reserved enhanced-schema keys are ordinary unknowns, and no keys means no section. Actions: Delete rides the same tombstone bytes as Backspace and drop-on-trash through a one-line seam, says nothing about selection, and lets the fate walk dismiss; Reveal in Finder resolves through the attachment scope so the two paths cannot disagree. History reserves its m7 slot without drawing a header no base board can honor. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
647 lines
28 KiB
Swift
647 lines
28 KiB
Swift
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<Choice> {
|
|
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<Bool> {
|
|
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()
|
|
}
|
|
)
|
|
}
|