Full relative text scaling per DESIGN/10: BoardMetrics is the board strip's geometry as a pure function of the body point size (CardWindowMetrics' twin) — lane plate/header/band, card corner/stripe/padding, masonry spacing, the drop model's nominal card height, resize-handle geometry, trash hatch pitch, and both window floors all derive from an em; CardFaceMetrics folded in. The two fixed font sizes (welcome brand/glyph) went relative; the toolbar search field is 17 ems like the transient bar's. The no-horizontal-scroll invariant is pinned by test at six text sizes by twelve lane counts. Accommodations is Motion's sibling for the visual settings: Increase Contrast adds a flat point to strokes (monotone, hierarchy-preserving), gives borderless card/lane plates a resting separator hairline, and takes faded accents to full alpha; Reduce Transparency turns the transient search bar's glass solid and does the same for the alpha washes that composite over a user-chosen board background (trash plate, hatched header, drag shadow). Reduce Motion audited — every animated surface already routes through Motion with a reduced variant; no gaps. Full Keyboard Access: the template chooser's tiles were pointer-only — now focusable, arrow-navigable (clamped, StyleWellGrid's rule), Space picks, Return stays the sheet's default action, focus names the selection one-way. The board's single tab stop shows its focus ring under FKA (focusEffectDisabled inverts). Style editor verified already conformant. Edge accents verified text-free; trash hatch pitch now font-derived so it still reads as hatching at large text. 1549 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
752 lines
35 KiB
Swift
752 lines
35 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.
|
|
///
|
|
/// ### Everything here scales with the body font
|
|
///
|
|
/// A well is a **container for a glyph**, and the glyph inside it is drawn at `.imageScale(.medium)`
|
|
/// — a relative size. So a well fixed at 20 points would be overrun by its own symbol at a large
|
|
/// system text size, and 10-accessibility.md's "every well Tab-reachable and labeled by name" would
|
|
/// be true of controls the user could no longer read. Every figure below is therefore a multiple of
|
|
/// the body point size, chosen to reproduce today's numbers at the standard 13pt body — the same
|
|
/// derivation, and the same rationale, as `BoardMetrics` and `CardWindowMetrics`.
|
|
struct StyleEditorLayout: Equatable {
|
|
|
|
/// One well's side, and the gap between two — the numbers the grids are laid out on, derived
|
|
/// once so the fit rule below and the wells themselves cannot drift apart.
|
|
///
|
|
/// 1.55 em and 0.45 em: 20pt and 6pt at the standard 13pt body, which is what the grids have
|
|
/// always drawn.
|
|
static func wellSide(bodyPointSize: CGFloat) -> CGFloat {
|
|
max(1, (bodyPointSize * 1.55).rounded())
|
|
}
|
|
|
|
static func wellSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
|
max(1, (bodyPointSize * 0.45).rounded())
|
|
}
|
|
|
|
/// The gap between the editor's two sections — and, at the popover anchor, its inset too, which
|
|
/// is why it is one figure rather than two that happen to agree. The *sidebar* anchor brings no
|
|
/// inset of its own (its column is already gutted) but still wants the sections apart, so the
|
|
/// spacing has to survive `padding` going to zero.
|
|
static func sectionSpacing(bodyPointSize: CGFloat) -> CGFloat {
|
|
max(1, (bodyPointSize * 1.08).rounded())
|
|
}
|
|
|
|
/// 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 well geometry this layout's grids draw on — carried on the value rather than read from
|
|
/// the statics above, so a view has one thing to consult and the two can never disagree about
|
|
/// which text size they were computed for.
|
|
var wellSide: CGFloat
|
|
var wellSpacing: 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.
|
|
///
|
|
/// The column counts are the design's own and stay fixed at every text size — 03-board-ui.md
|
|
/// names the 7 + 6 fall — while the *frame* around them grows, which is what keeps the 7 wells
|
|
/// inside it (20.6 em is 268pt at the standard body size, the number 03 settled on).
|
|
static func popover(bodyPointSize: CGFloat) -> StyleEditorLayout {
|
|
StyleEditorLayout(
|
|
width: (bodyPointSize * 20.6).rounded(),
|
|
padding: sectionSpacing(bodyPointSize: bodyPointSize),
|
|
backgroundColumns: 7,
|
|
symbolColumns: 8,
|
|
symbolGridMaximumHeight: (bodyPointSize * 12.9).rounded(),
|
|
wellSide: wellSide(bodyPointSize: bodyPointSize),
|
|
wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
|
|
)
|
|
}
|
|
|
|
/// 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, bodyPointSize: CGFloat) -> StyleEditorLayout {
|
|
let columns = columns(fitting: contentWidth, bodyPointSize: bodyPointSize)
|
|
return StyleEditorLayout(
|
|
width: nil,
|
|
padding: 0,
|
|
backgroundColumns: columns,
|
|
symbolColumns: columns,
|
|
symbolGridMaximumHeight: nil,
|
|
wellSide: wellSide(bodyPointSize: bodyPointSize),
|
|
wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
|
|
)
|
|
}
|
|
|
|
/// 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".
|
|
///
|
|
/// Both the column and the wells grow with the text size, so the count stays roughly stable
|
|
/// across text sizes rather than collapsing to one: `CardWindowMetrics`' sidebar is 26 body
|
|
/// *characters* wide and a well is 1.55 body *ems*, and the ratio between those does not move.
|
|
static func columns(fitting width: CGFloat, bodyPointSize: CGFloat) -> Int {
|
|
let side = wellSide(bodyPointSize: bodyPointSize)
|
|
let spacing = wellSpacing(bodyPointSize: bodyPointSize)
|
|
return max(1, Int((width + spacing) / (side + spacing)))
|
|
}
|
|
}
|
|
|
|
// 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`). `nil` takes the popover's, so
|
|
/// the two anchors that were here first say nothing about it — resolved in `body` rather than
|
|
/// defaulted in the declaration, because the popover's geometry now depends on the live text
|
|
/// size and a default argument cannot read one.
|
|
var layout: StyleEditorLayout?
|
|
|
|
/// The live body metric, read here rather than passed in — `CardStyleSection`'s pattern, so
|
|
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
|
|
/// rule).
|
|
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
|
|
|
var body: some View {
|
|
let layout = self.layout ?? .popover(bodyPointSize: pointSize)
|
|
let subjects = store.styleSubjects(of: target)
|
|
let background = StyleFieldState.resolve(subjects.map(\.background))
|
|
let icon = StyleFieldState.resolve(subjects.map(\.icon))
|
|
|
|
VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) {
|
|
targetCaption(count: subjects.count)
|
|
backgroundSection(background, layout: layout)
|
|
Divider()
|
|
symbolSection(icon, layout: layout)
|
|
}
|
|
.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, layout: StyleEditorLayout) -> some View {
|
|
VStack(alignment: .leading, spacing: layout.wellSpacing) {
|
|
sectionHeader("Background", current: backgroundCurrent(state), layout: layout)
|
|
StyleWellGrid(
|
|
wells: backgroundWells(state),
|
|
columns: layout.backgroundColumns,
|
|
layout: layout,
|
|
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, layout: StyleEditorLayout) -> some View {
|
|
let level = store.styleLevel(of: target)
|
|
let fallback = ItemSymbol.default(for: level)
|
|
return VStack(alignment: .leading, spacing: layout.wellSpacing) {
|
|
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback), layout: layout)
|
|
symbolGrid(state, fallback: fallback, layout: layout)
|
|
}
|
|
}
|
|
|
|
/// 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, layout: StyleEditorLayout) -> some View {
|
|
let grid = StyleWellGrid(
|
|
wells: symbolWells(state, fallback: fallback),
|
|
columns: layout.symbolColumns,
|
|
layout: layout,
|
|
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, layout: StyleEditorLayout) -> some View {
|
|
HStack(spacing: layout.wellSpacing) {
|
|
Text(title)
|
|
.font(.subheadline.weight(.semibold))
|
|
Spacer(minLength: layout.wellSpacing)
|
|
if let face = current.face {
|
|
// A touch smaller than a well: this is a *statement* of the current value, not a
|
|
// control, and it must not read as a fourteenth swatch that can be clicked.
|
|
StyleWellFace(face: face, size: (layout.wellSide * 0.7).rounded())
|
|
}
|
|
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
|
|
/// The well's side, supplied by the caller because it is font-derived and the caller is the one
|
|
/// holding the layout it came from (`StyleEditorLayout.wellSide`).
|
|
let size: CGFloat
|
|
|
|
/// Increase Contrast, for the swatch's border below (10-accessibility.md; `Accommodations`).
|
|
@Environment(\.colorSchemeContrast) private var contrast
|
|
|
|
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: cornerRadius)
|
|
.fill(color ?? Color(nsColor: .textBackgroundColor))
|
|
.overlay {
|
|
if color == nil {
|
|
NoValueStrike(inset: strikeInset)
|
|
.stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
|
}
|
|
}
|
|
// A point heavier under Increase Contrast — this hairline is the *only* thing separating
|
|
// a `chalk` well from the popover it sits on, which is the same reason it is drawn at all
|
|
// (10-accessibility.md's contrast stance turned on the app's own chrome).
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
|
)
|
|
.frame(width: size, height: size)
|
|
}
|
|
|
|
/// The swatch's radius and its "none" strike's inset, as fractions of the well — so both follow
|
|
/// the well when the text size grows it (10-accessibility.md's full-relative-scaling rule).
|
|
private var cornerRadius: CGFloat { max(1, (size * 0.2).rounded()) }
|
|
|
|
private var strikeInset: CGFloat { max(1, (size * 0.15).rounded()) }
|
|
|
|
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 {
|
|
/// How far in from each corner the stroke starts — a fraction of the well, supplied by the
|
|
/// caller, so it follows the well when the text size grows it.
|
|
let inset: CGFloat
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
var path = Path()
|
|
path.move(to: CGPoint(x: rect.minX + inset, y: rect.maxY - inset))
|
|
path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY + inset))
|
|
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
|
|
/// The anchor's geometry — the well side and spacing this grid lays out on
|
|
/// (`StyleEditorLayout`, all font-derived).
|
|
let layout: StyleEditorLayout
|
|
let apply: (StyleChange) -> Void
|
|
|
|
@FocusState private var focused: Int?
|
|
|
|
/// Increase Contrast, for the selection ring below — 10-accessibility.md names the selection
|
|
/// indicator specifically, and this is the style editor's ("the current value is stated by
|
|
/// trait", whose visible half is this ring).
|
|
@Environment(\.colorSchemeContrast) private var contrast
|
|
|
|
var body: some View {
|
|
LazyVGrid(
|
|
columns: Array(
|
|
repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
|
|
count: columns
|
|
),
|
|
spacing: layout.wellSpacing
|
|
) {
|
|
ForEach(wells) { well in
|
|
Button {
|
|
apply(well.change)
|
|
} label: {
|
|
StyleWellFace(face: well.face, size: layout.wellSide)
|
|
.overlay(selectionRing(well.isSelected))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
// **Every well is Tab-reachable and labeled by name** (10-accessibility.md ▸ Style
|
|
// editor). The focus ring is deliberately *not* disabled here, unlike the board
|
|
// strip's: a well is a control, and Full Keyboard Access has to be able to show
|
|
// which one Tab landed on.
|
|
.focusable()
|
|
.focused($focused, equals: well.id)
|
|
.help(well.label)
|
|
.accessibilityLabel(well.label)
|
|
// "The current value is stated by trait" — never by the highlight alone.
|
|
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
|
|
}
|
|
}
|
|
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
|
|
move(press.key)
|
|
}
|
|
}
|
|
|
|
/// The selected well's ring — a point heavier under Increase Contrast, which is 10's
|
|
/// "strengthens … the selection indicator" landing on the one selection indicator this component
|
|
/// has (`Accommodations`). The trait beside it is what makes the state readable without it.
|
|
private func selectionRing(_ isSelected: Bool) -> some View {
|
|
RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
|
|
.strokeBorder(
|
|
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
|
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
|
|
)
|
|
// Drawn *outside* the well, by the ring's own half-width, so a heavier ring under
|
|
// Increase Contrast grows outward instead of eating into the swatch it is marking.
|
|
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 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()
|
|
}
|
|
)
|
|
}
|