Owner's first review of the combo rework (2026-08-09): remove the face padding, make the field taller and narrower at about a 4:5 width:height ratio, and center the symbol glyph in its face. All three land in ComboFieldMetrics, so ColorComboControl and SymbolComboControl stay the identical shape they were built to share. - ComboFieldMetrics grows a width figure (height * widthToHeightRatio, 0.8), replacing NSView.noIntrinsicMetric — every combo now carries its own taller, narrower intrinsic size instead of stretching to whatever a caller's frame proposed. - facePaddingH/facePaddingV/glyphPadding are gone; a face fills its zone edge to edge. glyphPointSize is now whichever of the face's own width/height is smaller, with nothing subtracted for padding that no longer exists. - SymbolComboControl.drawFace centers the glyph on both axes — it only ever centered vertically before, despite its own doc comment claiming otherwise. - ComboFieldControl.drawTrigger bounds its chevron square by the smaller of the trigger strip's own width/height, not height alone, since the strip is no longer close to square once the field is much taller than it is wide. - CardSidebarSections drops the sidebar's old '* 0.55' fixed-width frame on both combo rows; each control now sizes itself, and both anchors (card sidebar, board popover) compose the narrower field with no other changes needed. - ComboFieldMetricsTests updated for the new figures, plus a ratio-holds-at-every-size test and a rewritten glyph-fit test matching the no-padding rule. Verification: xcodebuild build succeeded; xcodebuild test -only-testing:KanbanTests — 3220 tests in 559 suites, 3 failures, all PointerLatencyTests (documented locked-screen environmental mode, confirmed unrelated by isolated rerun). Pixel verification unexercised — same locked-screen constraint the first pass hit. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
261 lines
14 KiB
Swift
261 lines
14 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - Style
|
|
|
|
/// The sidebar's **Style** section: the **Background color combo** over the compact **`SymbolPicker`**
|
|
/// (05-card-window.md ▸ Style; 03-board-ui.md ▸ Styling ▸ Controls, its 2026-08-06 anchor-ownership
|
|
/// rule for the background half, extended to the symbol half on 2026-08-08). Card styling is
|
|
/// discoverable here without a context menu — the picker is the same reusable control the board
|
|
/// popover's glyph picker beside the rename field already uses (`BoardInfoPopover`).
|
|
///
|
|
/// ### Why the curated grid left
|
|
///
|
|
/// `StyleEditorView`'s curated grid used to draw **whole, uncollapsed** in this column
|
|
/// (`StyleEditorLayout.sidebar`'s `symbolGridMaximumHeight: nil` — "the sidebar is already a scroll
|
|
/// view, and a scroll view inside a scroll view is a scroll view that fights") — a five-to-seven-row
|
|
/// grid, permanently open, ahead of every other section the sidebar carries. `SymbolPicker` is the
|
|
/// general-purpose control built for precisely this shape of problem (its own header: "a control any
|
|
/// caller can aim at one symbol field without wiring up a `BoardStore`, a `StyleTarget`, or the
|
|
/// two-dimension batch machinery `StyleEditorView` carries") — one small well at rest, the grid only
|
|
/// while its popover is open. This section is the first caller to aim it at a store-backed field
|
|
/// rather than a plain binding, which is why it does the store plumbing below that `SymbolPicker`
|
|
/// itself deliberately knows nothing about.
|
|
///
|
|
/// **`StyleEditorView` is no longer instantiated here at all.** It was already `showsBackground:
|
|
/// false` — the color combo has owned the background half since 2026-08-06 — so the curated symbol
|
|
/// grid was its only remaining job in this section, and that job is what moved. The shared
|
|
/// component's `.sidebar` layout variant and `showsBackground` toggle stay in `StyleEditor.swift`
|
|
/// regardless: they are still correct and still tested (`StyleEditorLayoutTests`), and that file's own
|
|
/// stance is to be anchor-agnostic about callers it does not currently have. Retiring them as dead
|
|
/// code is a separate, larger cut this card does not make.
|
|
///
|
|
/// ### The curated set does not shrink
|
|
///
|
|
/// `SymbolPicker`'s own default (`SymbolPickerCatalog.defaultSet`, 36 general "boards and projects"
|
|
/// glyphs) is deliberately smaller than `CuratedSymbols.cards` (~40, work-item/content-flavored — one
|
|
/// of the three level-specific sets `CuratedSymbols` split into, 2026-08-09) — `SymbolPicker.swift`'s
|
|
/// own doc comment states why the lists are free to diverge. A card is exactly the level
|
|
/// `CuratedSymbols.cards` is grouped for, so this section passes it explicitly (`symbols:
|
|
/// CuratedSymbols.availableCards`) rather than taking the picker's domain-agnostic default: the
|
|
/// Style… popover's card-level grid and this section now offer the identical curated vocabulary,
|
|
/// through two different controls.
|
|
///
|
|
/// ### Writes still ride the one funnel, on this window's own stack
|
|
///
|
|
/// `onSelect`/`onSelectColor` below call `StyleCommand.apply(icon:iconColor:to:in:store:recents:on:)`
|
|
/// — the same funnel `StyleEditorView`'s wells rode, `on: undo` passed through exactly as before
|
|
/// (13-native-undo.md ▸ Rules ▸ two levels: a colour or symbol chosen here is a gesture *issued in
|
|
/// this window*, so its step joins the window's session and reaches board history only inside the
|
|
/// coarse close step — this section's own note on `undo` below, unchanged by the control swap).
|
|
/// Nothing about `BoardStore.applyStyle`'s batch semantics, its no-op skipping, or its `HistoryAnchor`
|
|
/// choice moved; only the view that calls it did — `CardSessionUndoTests.sidebarStylingIsASessionGesture`
|
|
/// calls that same funnel directly and needed no change.
|
|
///
|
|
/// ### The target is fixed, and that is the whole difference in behavior
|
|
///
|
|
/// The Style… popover *tracks*: its target is the selection at the moment the gesture named it,
|
|
/// re-resolved against every snapshot, dismissing when it empties (`StyleEditorSession`). This
|
|
/// section tracks nothing. Its target is this window's card, always — "the two embedded anchors need
|
|
/// none of this and get none: the card sidebar dismisses with its card's window, and the board
|
|
/// popover's target is the board itself" (`StyleEditorSession`'s own note). `SymbolPicker`'s own
|
|
/// popover presentation is plain view-local `@State`, needing no session for the same reason: the
|
|
/// window's card cannot vanish out from under its own window, and when it does the window goes with
|
|
/// it (`CardWindowFate`).
|
|
struct CardStyleSection: View {
|
|
|
|
let store: BoardStore
|
|
let recents: StyleRecents
|
|
let cardID: ItemID
|
|
/// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels): a colour or symbol
|
|
/// chosen here is a gesture *issued in this window*, so its step joins the window's session and
|
|
/// reaches board history only inside the coarse close step. The background combo below and the
|
|
/// symbol picker both take it as a parameter, for the same reason.
|
|
let undo: CardWindowUndo
|
|
|
|
/// The trailing debounce on a live colour-panel drag (`ColorComboView`'s `onPanelChange`,
|
|
/// opened from the combo's **Other…** row): cancelled and replaced on every tick, so only the
|
|
/// value the user is still on ~400ms after the last one actually reaches disk. One task for the
|
|
/// section's one combo.
|
|
@State private var backgroundPanelCommit: Task<Void, Never>?
|
|
|
|
/// The live body metric, read here rather than passed in — `CardAttachmentsSection`'s pattern,
|
|
/// so every section in this sidebar derives its geometry the same way.
|
|
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
|
|
|
/// **What this section styles: this card, and nothing else.**
|
|
///
|
|
/// A one-line seam because it is a claim worth pinning rather than a computation worth reading:
|
|
/// every other anchor derives its target from something that moves (the selection, the board),
|
|
/// and the mistake this window could make is inheriting one of those. `.items` of exactly one id
|
|
/// also means `applyStyle`'s own batch machinery is a no-op here — one subject, so the display is
|
|
/// never mixed and the write bracket holds one file.
|
|
nonisolated static func target(forCard id: ItemID) -> StyleTarget {
|
|
.items([id])
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
|
|
CardSidebarSectionHeader(title: "Style")
|
|
backgroundComboRow
|
|
symbolRow
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
// MARK: - Background combo
|
|
|
|
/// The labeled **Background** row, above the well grid — a narrower, single-value alternative
|
|
/// to it (`ColorCombo.swift`'s own doc comment): an inspector row, caption leading and a
|
|
/// compact combo trailing, the arrangement every Xcode inspector uses for exactly this control.
|
|
///
|
|
/// **No explicit width since 2026-08-09** — the combo used to be stretched to just over half the
|
|
/// row (`CardWindowMetrics.sidebarContentWidth(...) * 0.55`), which was the wide-bar shape the
|
|
/// owner's iteration undid. `ComboFieldMetrics` now carries its own taller, narrower intrinsic
|
|
/// width, so the row hands the combo no frame at all and lets it size itself; the leading
|
|
/// `Spacer(minLength: 8)` still pins it to the row's trailing edge.
|
|
private var backgroundComboRow: some View {
|
|
HStack(spacing: 0) {
|
|
Text("Background")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer(minLength: 8)
|
|
ColorComboView(
|
|
role: .background,
|
|
value: currentBackground,
|
|
isEnabled: !store.isReadOnly,
|
|
onChange: { commitBackground($0) },
|
|
onPanelChange: { debounceBackground($0) }
|
|
)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
/// The card's `background` field, exactly as written — malformed reads as its raw text, missing
|
|
/// reads `nil`, both `StyleFieldState.written`'s own rule (`StyleModel.swift`). The **raw**
|
|
/// string, never a resolved colour: `ColorComboModel`'s matching needs the bytes, not what they
|
|
/// render as.
|
|
private var currentBackground: String? {
|
|
StyleFieldState.written(currentSubject?.background ?? .missing)
|
|
}
|
|
|
|
/// A discrete pick — commits immediately. `nil` removes; a name from `Palette.backgrounds` goes
|
|
/// through `StyleCommand.apply` so it feeds `StyleRecents` exactly like a well click would
|
|
/// ("updated on every background application from any anchor", `StyleEditor.swift`); anything
|
|
/// else — the dynamic current-value row re-affirming a foreign name or a custom hex — writes
|
|
/// directly, since it is not the "palette pick" recents was ever meant to remember.
|
|
private func commitBackground(_ newValue: String?) {
|
|
let target = Self.target(forCard: cardID)
|
|
guard let newValue else {
|
|
store.applyStyle(to: target, background: .remove, icon: .keep, on: undo)
|
|
return
|
|
}
|
|
if Palette.backgrounds.contains(where: { $0.name == newValue }) {
|
|
StyleCommand.apply(background: .set(newValue), to: target, in: store, recents: recents, on: undo)
|
|
} else {
|
|
store.applyStyle(to: target, background: .set(newValue), icon: .keep, on: undo)
|
|
}
|
|
}
|
|
|
|
/// One tick of a live colour-panel drag: cancels whatever commit was pending and schedules a new
|
|
/// one ~400ms out, so a drag writes once it settles rather than on every pixel it passes through.
|
|
/// Never routed through `StyleCommand.apply` — a drag that passes through a palette-exact hex
|
|
/// mid-gesture must not spam the recents row the way a deliberate pick would.
|
|
private func debounceBackground(_ newValue: String?) {
|
|
backgroundPanelCommit?.cancel()
|
|
let target = Self.target(forCard: cardID)
|
|
backgroundPanelCommit = Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(400))
|
|
guard !Task.isCancelled else { return }
|
|
if let newValue {
|
|
store.applyStyle(to: target, background: .set(newValue), icon: .keep, on: undo)
|
|
} else {
|
|
store.applyStyle(to: target, background: .remove, icon: .keep, on: undo)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Symbol
|
|
|
|
/// The labeled **Symbol** row, below the background combo — `backgroundComboRow`'s own
|
|
/// inspector-row shape, restated: caption leading, the combo trailing, **at the same size**.
|
|
///
|
|
/// That size used to be the difference between the two rows. The picker was a 20pt bordered
|
|
/// square with one hit zone sitting under a wide two-zone colour combo, and this is the sidebar
|
|
/// where the mismatch was most visible — two adjacent rows setting two adjacent keys, looking
|
|
/// like different kinds of control. Since the 2026-08-09 rework `SymbolPicker` *is* a combo
|
|
/// (`ComboField.swift`), so the two rows need no frame of their own to agree — both controls read
|
|
/// their geometry off the identical `ComboFieldMetrics`, `backgroundComboRow`'s own note.
|
|
private var symbolRow: some View {
|
|
HStack(spacing: 0) {
|
|
Text("Symbol")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer(minLength: 8)
|
|
SymbolPicker(
|
|
current: currentSymbol,
|
|
fallback: ItemSymbol.card,
|
|
symbols: CuratedSymbols.availableCards,
|
|
onSelect: { applySymbol($0) },
|
|
currentColor: currentIconColor,
|
|
onSelectColor: { applyIconColor($0) }
|
|
)
|
|
// The same lock `StyleEditorView`'s whole body disabled under
|
|
// (`.disabled(!store.acceptsBoardMutations)`, `StyleEditor.swift`) — the read-only lock
|
|
// and the board's inline-editing rule alike, preserved exactly across the control swap
|
|
// rather than narrowed to `backgroundComboRow`'s plainer `!store.isReadOnly`.
|
|
.disabled(!store.acceptsBoardMutations)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
/// One `styleSubjects` lookup behind `currentBackground` above and the two properties below —
|
|
/// a card window names exactly one target, so there is exactly one subject to read for all three
|
|
/// fields.
|
|
private var currentSubject: BoardStore.StyleSubject? {
|
|
store.styleSubjects(of: Self.target(forCard: cardID)).first
|
|
}
|
|
|
|
/// The card's `icon` field, exactly as written — `currentBackground`'s rule, restated for the
|
|
/// field `SymbolPicker.current` reads. Resolution (an unresolvable name falling back to the level
|
|
/// default) is `SymbolPicker`'s own job, not this property's — `ItemSymbol.name(_:fallback:)`'s
|
|
/// lenient rule, which the picker's `resolvedName` already applies.
|
|
private var currentSymbol: String? {
|
|
StyleFieldState.written(currentSubject?.icon ?? .missing)
|
|
}
|
|
|
|
/// The card's `iconColor` field, exactly as written — one dimension over from `currentSymbol`,
|
|
/// feeding `SymbolPicker.currentColor`.
|
|
private var currentIconColor: String? {
|
|
StyleFieldState.written(currentSubject?.iconColor ?? .missing)
|
|
}
|
|
|
|
/// `nil` clears back to the card default (`icon` removed); a name sets it — `StyleChange`'s
|
|
/// `set`/`remove` split, `SymbolPicker.onSelect`'s own contract turned into the vocabulary
|
|
/// `StyleCommand.apply` speaks. Routed through the funnel rather than `store.applyStyle` directly
|
|
/// so a symbol pick joins `StyleRecents` exactly as a background pick does — moot today (only
|
|
/// `.set(background:)` records, `StyleCommand.apply`'s own rule), but future-proof against that
|
|
/// rule ever widening to symbols.
|
|
private func applySymbol(_ name: String?) {
|
|
StyleCommand.apply(
|
|
icon: name.map { StyleChange.set($0) } ?? .remove,
|
|
to: Self.target(forCard: cardID),
|
|
in: store,
|
|
recents: recents,
|
|
on: undo
|
|
)
|
|
}
|
|
|
|
/// `nil` clears the tint (`iconColor` removed); a palette name sets it — `applySymbol`'s sibling,
|
|
/// one dimension over, exactly as `BoardInfoPopover`'s own `onSelectColor` writes the board's.
|
|
private func applyIconColor(_ name: String?) {
|
|
StyleCommand.apply(
|
|
iconColor: name.map { StyleChange.set($0) } ?? .remove,
|
|
to: Self.target(forCard: cardID),
|
|
in: store,
|
|
recents: recents,
|
|
on: undo
|
|
)
|
|
}
|
|
}
|