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:
@@ -367,8 +367,10 @@ extension BoardStore {
|
||||
///
|
||||
/// `.remove` is spelled as `nil`, which is the after-value the None well leaves: the key is gone,
|
||||
/// and the item renders the level's default (03-board-ui.md § Styling ▸ Controls).
|
||||
static func styledFields(background: StyleChange, icon: StyleChange) -> [ExpectedField] {
|
||||
field(background, as: ExpectedField.background) + field(icon, as: ExpectedField.icon)
|
||||
static func styledFields(background: StyleChange, icon: StyleChange, iconColor: StyleChange) -> [ExpectedField] {
|
||||
field(background, as: ExpectedField.background)
|
||||
+ field(icon, as: ExpectedField.icon)
|
||||
+ field(iconColor, as: ExpectedField.iconColor)
|
||||
}
|
||||
|
||||
/// The same dimensions, holding the values the *inverse* restores — what the redo half validates
|
||||
@@ -381,7 +383,9 @@ extension BoardStore {
|
||||
background: StyleChange,
|
||||
priorBackground: FieldValue<String>,
|
||||
icon: StyleChange,
|
||||
priorIcon: FieldValue<String>
|
||||
priorIcon: FieldValue<String>,
|
||||
iconColor: StyleChange,
|
||||
priorIconColor: FieldValue<String>
|
||||
) -> [ExpectedField] {
|
||||
var fields: [ExpectedField] = []
|
||||
if background != .keep {
|
||||
@@ -390,6 +394,9 @@ extension BoardStore {
|
||||
if icon != .keep {
|
||||
fields.append(.icon(priorIcon.value))
|
||||
}
|
||||
if iconColor != .keep {
|
||||
fields.append(.iconColor(priorIconColor.value))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ public final class CardWindowUndo {
|
||||
}
|
||||
|
||||
private static let fieldOrder: [ExpectedField.Kind] = [
|
||||
.title, .order, .width, .background, .backgroundImage, .icon, .body,
|
||||
.title, .order, .width, .background, .backgroundImage, .icon, .iconColor, .body,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
/// `icon` — the styling gesture's symbol dimension.
|
||||
case icon(String?)
|
||||
|
||||
/// `iconColor` — the symbol's tint, the styling gesture's third dimension since the board
|
||||
/// popover's symbol picker grew its colour row (2026-08-07; previously "schema yes, control
|
||||
/// no"). `nil` is the removed key — the None well — exactly as everywhere else here.
|
||||
case iconColor(String?)
|
||||
|
||||
/// The body span, **byte for byte** — the Edit session's step, and the one inverse in the app
|
||||
/// whose fidelity is not field-level (13: "body steps compare bytes").
|
||||
case body(String)
|
||||
@@ -57,6 +62,7 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
case .background: .background
|
||||
case .backgroundImage: .backgroundImage
|
||||
case .icon: .icon
|
||||
case .iconColor: .iconColor
|
||||
case .body: .body
|
||||
}
|
||||
}
|
||||
@@ -70,6 +76,7 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
case background
|
||||
case backgroundImage
|
||||
case icon
|
||||
case iconColor
|
||||
case body
|
||||
}
|
||||
}
|
||||
@@ -332,6 +339,7 @@ public enum HistoryStaleness {
|
||||
case let .background(expected): equal(document.background, expected)
|
||||
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
|
||||
case let .icon(expected): equal(document.icon, expected)
|
||||
case let .iconColor(expected): equal(document.iconColor, expected)
|
||||
case let .body(expected): document.body == expected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1698,8 +1698,8 @@ public final class BoardStore: HealHost {
|
||||
|
||||
// MARK: - Styling
|
||||
|
||||
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
|
||||
/// keys currently say there.
|
||||
/// One item a style gesture is about to act on: where its `index.md` is, and what the three
|
||||
/// styled keys currently say there.
|
||||
///
|
||||
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
|
||||
/// and `applyStyle` reads the *same* values to decide what is a no-op, so the display and the
|
||||
@@ -1710,6 +1710,7 @@ public final class BoardStore: HealHost {
|
||||
public let folder: URL
|
||||
public let background: FieldValue<String>
|
||||
public let icon: FieldValue<String>
|
||||
public let iconColor: FieldValue<String>
|
||||
}
|
||||
|
||||
/// The live items `target` names, in display order — lanes left to right, each lane's cards top
|
||||
@@ -1727,7 +1728,8 @@ public final class BoardStore: HealHost {
|
||||
id: nil,
|
||||
folder: rootURL,
|
||||
background: snapshot.background,
|
||||
icon: snapshot.icon
|
||||
icon: snapshot.icon,
|
||||
iconColor: snapshot.iconColor
|
||||
)]
|
||||
|
||||
case let .items(ids):
|
||||
@@ -1739,7 +1741,8 @@ public final class BoardStore: HealHost {
|
||||
id: lane.id,
|
||||
folder: laneFolder,
|
||||
background: lane.background,
|
||||
icon: lane.icon
|
||||
icon: lane.icon,
|
||||
iconColor: lane.iconColor
|
||||
))
|
||||
}
|
||||
for card in lane.cards where ids.contains(card.id) {
|
||||
@@ -1747,7 +1750,8 @@ public final class BoardStore: HealHost {
|
||||
id: card.id,
|
||||
folder: laneFolder.appendingPathComponent(card.id.rawValue),
|
||||
background: card.background,
|
||||
icon: card.icon
|
||||
icon: card.icon,
|
||||
iconColor: card.iconColor
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1790,9 +1794,10 @@ public final class BoardStore: HealHost {
|
||||
/// no-ops is dropped entirely; and a gesture that changes nothing anywhere never opens the
|
||||
/// bracket at all.
|
||||
///
|
||||
/// **`iconColor` is not a parameter, and that is the design**: it is "resolved — schema yes,
|
||||
/// control no" (§ Capabilities). The field renders when hand-written and the app offers no
|
||||
/// control for it, so there is nothing here to pass.
|
||||
/// **`iconColor` joined as the third dimension with the board popover's colour row**
|
||||
/// (2026-08-07). It had been "resolved — schema yes, control no" (§ Capabilities); the symbol
|
||||
/// picker's colour grid is the control that ended that, and it rides this funnel exactly as the
|
||||
/// other two dimensions do — per-dimension no-op skipping, one bracket, one history step.
|
||||
///
|
||||
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
|
||||
/// like every other gesture with no second thing to do. A batch that fails partway leaves the
|
||||
@@ -1816,6 +1821,7 @@ public final class BoardStore: HealHost {
|
||||
to target: StyleTarget,
|
||||
background: StyleChange = .keep,
|
||||
icon: StyleChange = .keep,
|
||||
iconColor: StyleChange = .keep,
|
||||
on window: CardWindowUndo? = nil
|
||||
) {
|
||||
// A session gesture anchors to the card, everything else to the folder it resolved (above).
|
||||
@@ -1829,13 +1835,16 @@ public final class BoardStore: HealHost {
|
||||
anchor: HistoryAnchor,
|
||||
background: StyleChange,
|
||||
icon: StyleChange,
|
||||
iconColor: StyleChange,
|
||||
priorBackground: FieldValue<String>,
|
||||
priorIcon: FieldValue<String>
|
||||
priorIcon: FieldValue<String>,
|
||||
priorIconColor: FieldValue<String>
|
||||
)] = styleSubjects(of: target)
|
||||
.compactMap { subject in
|
||||
let background = Self.effective(background, against: subject.background)
|
||||
let icon = Self.effective(icon, against: subject.icon)
|
||||
guard background != .keep || icon != .keep else { return nil }
|
||||
let iconColor = Self.effective(iconColor, against: subject.iconColor)
|
||||
guard background != .keep || icon != .keep || iconColor != .keep else { return nil }
|
||||
let anchor: HistoryAnchor = if anchorsByIdentity, let id = subject.id {
|
||||
.card(id)
|
||||
} else {
|
||||
@@ -1847,8 +1856,10 @@ public final class BoardStore: HealHost {
|
||||
anchor: anchor,
|
||||
background: background,
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
priorBackground: subject.background,
|
||||
priorIcon: subject.icon
|
||||
priorIcon: subject.icon,
|
||||
priorIconColor: subject.iconColor
|
||||
)
|
||||
}
|
||||
guard !edits.isEmpty else { return }
|
||||
@@ -1866,6 +1877,7 @@ public final class BoardStore: HealHost {
|
||||
) { document in
|
||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||
Self.apply(edit.iconColor, to: FrontmatterKeys.iconColor, in: &document)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1891,14 +1903,20 @@ public final class BoardStore: HealHost {
|
||||
subject: subject,
|
||||
on: window,
|
||||
undoExpects: edits.map {
|
||||
.present($0.anchor, fields: Self.styledFields(background: $0.background, icon: $0.icon))
|
||||
.present($0.anchor, fields: Self.styledFields(
|
||||
background: $0.background,
|
||||
icon: $0.icon,
|
||||
iconColor: $0.iconColor
|
||||
))
|
||||
},
|
||||
redoExpects: edits.map {
|
||||
.present($0.anchor, fields: Self.restoredStyleFields(
|
||||
background: $0.background,
|
||||
priorBackground: $0.priorBackground,
|
||||
icon: $0.icon,
|
||||
priorIcon: $0.priorIcon
|
||||
priorIcon: $0.priorIcon,
|
||||
iconColor: $0.iconColor,
|
||||
priorIconColor: $0.priorIconColor
|
||||
))
|
||||
}
|
||||
) { store in
|
||||
@@ -1910,6 +1928,7 @@ public final class BoardStore: HealHost {
|
||||
) { document in
|
||||
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
|
||||
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
||||
Self.restore(edit.priorIconColor, to: FrontmatterKeys.iconColor, in: &document)
|
||||
}
|
||||
}
|
||||
} redo: { store in
|
||||
@@ -1921,6 +1940,7 @@ public final class BoardStore: HealHost {
|
||||
) { document in
|
||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||
Self.apply(edit.iconColor, to: FrontmatterKeys.iconColor, in: &document)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,18 @@ struct BoardInfoWidget: View {
|
||||
presentation.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
// The board's own glyph beside its name — the identity pair the popover header
|
||||
// states, restated where the board is named all day (2026-08-07). Read inside
|
||||
// `body` for `summary`'s reason: `icon`/`iconColor` are `@Observable` fields, so a
|
||||
// restyle from the popover repaints this widget without reinstalling it. Lenient on
|
||||
// both dimensions — an unresolvable glyph draws the board default, an unresolvable
|
||||
// tint draws the standard secondary.
|
||||
Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board))
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(iconTint)
|
||||
// Decorative beside the name it repeats — the button's own label already says
|
||||
// everything VoiceOver needs (`accessibilityLabel` below).
|
||||
.accessibilityHidden(true)
|
||||
Text(summary.title)
|
||||
// Styled like a titlebar title, because that is what it now stands in for
|
||||
// (`BoardWindowHost` hides the system title display in favor of this widget).
|
||||
@@ -172,6 +184,15 @@ struct BoardInfoWidget: View {
|
||||
guard let branch = summary.branch else { return summary.title }
|
||||
return "\(summary.title), branch \(branch)"
|
||||
}
|
||||
|
||||
/// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
|
||||
/// otherwise — `CardFaceView`'s `iconTint` rule at the board's own level.
|
||||
private var iconTint: AnyShapeStyle {
|
||||
if let color = Palette.color(for: store.snapshot.iconColor) {
|
||||
return AnyShapeStyle(color)
|
||||
}
|
||||
return AnyShapeStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The widget's strings
|
||||
@@ -352,6 +373,18 @@ struct BoardInfoView: View {
|
||||
in: store,
|
||||
recents: recents
|
||||
)
|
||||
},
|
||||
// The colour row — the picker's 4×2 tint grid, writing `iconColor` through
|
||||
// the same funnel the glyph writes `icon`: None removes the key, a well
|
||||
// writes the palette name (2026-08-07).
|
||||
currentColor: store.snapshot.iconColor.value,
|
||||
onSelectColor: { name in
|
||||
StyleCommand.apply(
|
||||
iconColor: name.map { StyleChange.set($0) } ?? .remove,
|
||||
to: .board,
|
||||
in: store,
|
||||
recents: recents
|
||||
)
|
||||
}
|
||||
)
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
@@ -36,12 +36,13 @@ enum StyleCommand {
|
||||
static func apply(
|
||||
background: StyleChange = .keep,
|
||||
icon: StyleChange = .keep,
|
||||
iconColor: StyleChange = .keep,
|
||||
to target: StyleTarget,
|
||||
in store: BoardStore,
|
||||
recents: StyleRecents,
|
||||
on undo: CardWindowUndo? = nil
|
||||
) {
|
||||
store.applyStyle(to: target, background: background, icon: icon, on: undo)
|
||||
store.applyStyle(to: target, background: background, icon: icon, iconColor: iconColor, on: undo)
|
||||
if case let .set(value) = background {
|
||||
recents.record(value)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user