The board's symbol takes a tint — a 4×2 colour row under the picker's glyphs, and the glyph itself moves into the titlebar

iconColor stops being hand-written-only (user-ruled, superseding 03's
"schema yes, control no"): it rides StyleCommand.apply → applyStyle as
the third styled dimension — per-dimension no-op skip, one bracket, one
history step, ExpectedField.iconColor for staleness. The SymbolPicker
grows an opt-in colour row (leading None plus seven Palette.foregrounds
hues, None removes the key); the board popover is its one caller. The
window-title widget now draws the board's resolved glyph in that tint
beside the name. Doc realignment filed on the Redesign board (Minor).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 18:36:49 -04:00
parent 3d231d6454
commit 7ac34651a2
12 changed files with 342 additions and 23 deletions
+181
View File
@@ -49,6 +49,16 @@ enum SymbolPickerCatalog {
/// list is a convenience, never a claim about the running system.
static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
/// The colour row's seven tints `Palette.foregrounds`' hues, minus the four grayscale steps
/// (a symbol's *tint* wants colour; "no tint" is the None well's job, not a gray's) and minus
/// `deep-cool-granite`, the mutedest of the eight, dropped so the row plus its leading None
/// fills the 4×2 grid exactly. Palette names, not hexes, exactly as the style editor's wells
/// write them.
static let colorSet: [String] = [
"carnation", "rich-grapefruit", "smokey-tangerine", "fern",
"light-teal", "deep-sky-blue", "pale-violet",
]
/// Where the OS keeps the full SF Symbols inventory read-only system metadata, present on
/// every Mac that ships SF Symbols at all.
private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"
@@ -131,6 +141,10 @@ struct SymbolPickerLayout: Equatable {
static let columns = 6
static let rows = 6
/// The colour row's own shape 4×2, the leading None plus `SymbolPickerCatalog.colorSet`'s
/// seven tints.
static let colorColumns = 4
static let colorRows = 2
/// The grid's enlargement over the style editor's well size glyphs read at a glance rather
/// than in miniature.
static let gridScale: CGFloat = 1.3
@@ -153,6 +167,10 @@ struct SymbolPickerLayout: Equatable {
/// The search grid's scroll cap six rows tall, so a long result list scrolls inside the popover
/// rather than growing it.
var gridHeight: CGFloat
/// A colour well's width: the symbol grid's width re-divided into four columns, so the colour
/// rows sit flush under the symbol grid rather than introducing a second width. Height stays
/// `wellSide` the swatch is wide, not tall.
var colorWellWidth: CGFloat
/// The grid's width plus its padding on both sides the popover's fixed width.
var popoverWidth: CGFloat
@@ -172,6 +190,7 @@ struct SymbolPickerLayout: Equatable {
contentPadding: padding,
gridWidth: gridWidth,
gridHeight: gridHeight,
colorWellWidth: ((gridWidth - spacing * CGFloat(colorColumns - 1)) / CGFloat(colorColumns)).rounded(.down),
popoverWidth: (gridWidth + padding * 2).rounded()
)
}
@@ -210,6 +229,13 @@ struct SymbolPicker: View {
/// split without importing that type, since a caller outside the styling system has no `StyleChange`
/// to hand back.
let onSelect: (String?) -> Void
/// The committed tint (`iconColor`), or `nil` for "no tint" read only when `onSelectColor` is
/// wired, since a picker with no colour row has no tint to state.
var currentColor: String? = nil
/// The colour row's contract, `onSelect`'s shape one dimension over: a palette name to set, or
/// `nil` to clear the tint. **`nil` here means no colour row at all** the grid is opt-in, so
/// the callers that wanted a symbol picker keep getting exactly one.
var onSelectColor: ((String?) -> Void)? = nil
@State private var isPresented = false
@@ -223,6 +249,13 @@ struct SymbolPicker: View {
return fallback
}
/// The at-rest well's tint, or `nil` for the standard one `Palette`'s lenient rule, gated on
/// the colour row being offered at all.
private var resolvedTint: AnyShapeStyle? {
guard onSelectColor != nil, let currentColor, let color = Palette.color(named: currentColor) else { return nil }
return AnyShapeStyle(color)
}
var body: some View {
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
Button {
@@ -230,6 +263,10 @@ struct SymbolPicker: View {
} label: {
Image(systemName: resolvedName)
.imageScale(.medium)
// The tint the board actually renders with, on the well that states the board's
// glyph shown only where the picker offers the colour row, and lenient exactly
// like the glyph itself: an unresolvable value tints nothing.
.foregroundStyle(resolvedTint ?? AnyShapeStyle(.primary))
.frame(width: layout.restSide, height: layout.restSide)
}
.buttonStyle(.bordered)
@@ -246,6 +283,13 @@ struct SymbolPicker: View {
onSelect: { name in
onSelect(name)
isPresented = false
},
currentColor: currentColor,
onSelectColor: onSelectColor.map { select in
{ name in
select(name)
isPresented = false
}
}
)
}
@@ -265,6 +309,9 @@ private struct SymbolPickerPopoverContent: View {
let searchable: Bool
let layout: SymbolPickerLayout
let onSelect: (String?) -> Void
var currentColor: String? = nil
/// `nil` is "no colour row" `SymbolPicker.onSelectColor`'s opt-in, passed through.
var onSelectColor: ((String?) -> Void)? = nil
@State private var query = ""
@@ -274,6 +321,12 @@ private struct SymbolPickerPopoverContent: View {
searchField
}
resultBody
// The colour row rides below whichever grid is up a search narrows the symbols, not
// the tints, so the row keeps standing where the eye left it.
if let onSelectColor {
Divider()
SymbolColorGrid(current: currentColor, layout: layout, onSelect: onSelectColor)
}
}
.padding(layout.contentPadding)
.frame(width: layout.popoverWidth)
@@ -465,3 +518,131 @@ private struct SymbolWellGrid: View {
return .handled
}
}
// MARK: - The colour row
/// One well in the colour grid: a palette name, or `nil` for the leading None.
private struct SymbolColorWell: Identifiable {
let id: Int
/// The palette name this well writes, or `nil` for the None well the removal.
let name: String?
let label: String
let isSelected: Bool
}
/// The tint grid under the symbol grid the leading **None** well and
/// `SymbolPickerCatalog.colorSet`'s seven tints, 4×2 (the board popover's colour row, 2026-08-07).
/// `StyleWellGrid`'s pattern one more time, and `SymbolWellGrid`'s reason for restating it: the
/// style editor's grids are that file's own, and this row's shape (wide swatches on a fixed
/// four-column re-division of the symbol grid's width) fits neither.
private struct SymbolColorGrid: View {
/// The committed tint as written, or `nil` when the key is absent.
let current: String?
let layout: SymbolPickerLayout
let onSelect: (String?) -> Void
@FocusState private var focused: Int?
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
LazyVGrid(
columns: Array(
repeating: GridItem(.flexible(minimum: layout.colorWellWidth), spacing: layout.wellSpacing),
count: SymbolPickerLayout.colorColumns
),
spacing: layout.wellSpacing
) {
ForEach(wells) { well in
Button {
onSelect(well.name)
} label: {
swatch(well.name.flatMap(Palette.color(named:)))
.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)
}
}
/// The None well leads, selected whenever no tint would render a missing key and an
/// unresolvable value read the same way here, `ItemSymbol.name(_:fallback:)`'s lenient rule
/// turned on the colour dimension.
private var wells: [SymbolColorWell] {
let isNoneSelected = current.map { Palette.color(named: $0) == nil } ?? true
var wells = [SymbolColorWell(id: 0, name: nil, label: "No Color", isSelected: isNoneSelected)]
for (index, name) in SymbolPickerCatalog.colorSet.enumerated() {
wells.append(SymbolColorWell(id: index + 1, name: name, label: name, isSelected: current == name))
}
return wells
}
/// A colour swatch, always stroked (`chalk`'s lesson from the style editor's wells: a pale
/// swatch with no border is an invisible control), with the corner-to-corner slash standing in
/// for a colour on the None well Finder's own vocabulary for "there isn't one".
private func swatch(_ color: Color?) -> some View {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(color ?? Color(nsColor: .textBackgroundColor))
.overlay {
if color == nil {
ColorNoneStrike(inset: max(1, (layout.wellSide * 0.15).rounded()))
.stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
}
}
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
)
.frame(width: layout.colorWellWidth, height: layout.wellSide)
}
private var cornerRadius: CGFloat { max(1, (layout.wellSide * 0.25).rounded()) }
private func selectionRing(_ isSelected: Bool) -> some View {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
)
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
}
/// `SymbolWellGrid.move(_:)`, at this grid's own four columns.
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
let delta: Int
switch key {
case .leftArrow: delta = -1
case .rightArrow: delta = 1
case .upArrow: delta = -SymbolPickerLayout.colorColumns
case .downArrow: delta = SymbolPickerLayout.colorColumns
default: return .ignored
}
let current = focused ?? 0
let next = min(max(0, current + delta), wells.count - 1)
focused = next
return .handled
}
}
/// The None well's corner-to-corner slash `StyleEditor.swift`'s `NoValueStrike`, restated as a
/// sibling for `SymbolWellFace`'s reason: that type is private to a file whose whole point is
/// staying anchor-agnostic, and one two-point path is cheaper than widening it.
private struct ColorNoneStrike: Shape {
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
}
}