A reusable symbol picker — the board's glyph joins its name in the info popover
SymbolPicker: one well at rest, a 6x6 curated grid in a popover (leading well = the level default, clearing the key), and an optional search over the OS's full symbol inventory read from CoreGlyphs metadata. Geometry font-derived off StyleEditorLayout's base, grid enlarged by a deliberate 1.3x. Wired inline with the rename field in the board info popover through the StyleCommand funnel; the Title header retires. Curated set and the search's standing vs 03's full-browser refusal await ratification.
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// **A reusable SF Symbol picker** — a single well showing the resolved symbol, opening a curated
|
||||
/// grid with a search escape hatch (03-board-ui.md § Styling ▸ Controls: "its leading well is the
|
||||
/// level's default symbol and removes the `icon` key … Any other SF Symbol name works written by
|
||||
/// hand … No full-browser escape hatch in-app; the raw file is the escape hatch"). `StyleEditor.swift`
|
||||
/// already builds that grid once, aimed at `background`/`icon` together and multiplexed across three
|
||||
/// anchors; this file builds the *symbol half alone*, aimed at any single field a caller names, so a
|
||||
/// control that only ever needs one glyph — a saved search, a smart filter, a future per-item
|
||||
/// affordance — is not forced to carry the style editor's background section or its `BoardStore`
|
||||
/// coupling to get one.
|
||||
///
|
||||
/// ### Why the curated set differs from `CuratedSymbols`
|
||||
///
|
||||
/// `CuratedSymbols.all` is grouped by what a *board item* is (status/flow, containers, people…) —
|
||||
/// this control has no board item in mind, so `SymbolPickerCatalog.defaultSet` is a smaller,
|
||||
/// ungrouped 36 chosen for the general "boards and projects" case instead. The two lists are free to
|
||||
/// diverge; nothing here reads the other.
|
||||
///
|
||||
/// ### The one thing `CuratedSymbols` never needed
|
||||
///
|
||||
/// The style editor's curated grid has no search and no full-catalog fallback ("no full-browser
|
||||
/// escape hatch in-app" is a statement about *that* surface). This picker adds one anyway, because a
|
||||
/// general-purpose control cannot assume its 36 will always contain what the caller is after — a
|
||||
/// search with nothing to search would just move the dead end from "no matching well" to "no way to
|
||||
/// look further".
|
||||
|
||||
// MARK: - The symbol catalogs
|
||||
|
||||
/// The picker's two symbol lists: the curated 36-glyph grid it opens with, and the OS's full
|
||||
/// inventory it searches into once the grid alone isn't enough.
|
||||
enum SymbolPickerCatalog {
|
||||
|
||||
/// The picker's curated grid, in order — a general "boards and projects" set rather than the
|
||||
/// style editor's kanban-item groupings, chosen so a first-run picker with no caller-supplied
|
||||
/// `symbols` still shows something broadly useful. A stored constant, not a computed property,
|
||||
/// for `CuratedSymbols.all`'s own reason: the list is the design decision, and `available` is the
|
||||
/// only thing the OS gets a say in.
|
||||
static let defaultSet: [String] = [
|
||||
"star", "flag", "heart", "bolt", "flame", "leaf", "drop", "sun.max", "moon", "sparkles",
|
||||
"tag", "bookmark", "pin", "bell", "paperplane", "tray", "folder", "archivebox", "doc.text",
|
||||
"list.bullet", "checklist", "calendar", "clock", "hammer", "wrench.and.screwdriver",
|
||||
"paintbrush", "lightbulb", "brain", "book", "graduationcap", "briefcase", "cart", "house",
|
||||
"airplane", "gamecontroller", "globe",
|
||||
]
|
||||
|
||||
/// The set this Mac can actually draw — `CuratedSymbols.available`'s rule, mirrored: a curated
|
||||
/// list is a convenience, never a claim about the running system.
|
||||
static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
|
||||
|
||||
/// 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"
|
||||
|
||||
/// The default path's catalog, loaded once. A `static let` rather than a `lazy var`: the load is
|
||||
/// synchronous and the result is a plain `[String]` — Sendable, immutable once computed — so
|
||||
/// Swift's usual thread-safe one-time global initialization is the whole of the "cache" this
|
||||
/// needs, with no actor to hang it off.
|
||||
private static let cachedFullCatalog: [String] = load(bundlePath: defaultBundlePath)
|
||||
|
||||
/// Every SF Symbol name the running OS knows, sorted and deduplicated — the search grid's source.
|
||||
///
|
||||
/// **Not filtered through `ItemSymbol.exists`.** The plist this reads already reflects the
|
||||
/// running OS's own inventory (it *is* the OS's inventory), and running a few thousand
|
||||
/// `NSImage(systemSymbolName:)` lookups against it on every search keystroke would be pure cost
|
||||
/// for an answer the file has already given for free. A curated list is different: it is a
|
||||
/// hand-written guess that might be stale, and only guesses need checking.
|
||||
///
|
||||
/// `bundlePath` defaults to the real system location and is cached there; any other path — the
|
||||
/// test suite's nonexistent one, chiefly — reloads (and re-falls-back) on every call, which is
|
||||
/// the honest cost of asking a question the cache was never built to answer.
|
||||
static func fullCatalog(bundlePath: String = defaultBundlePath) -> [String] {
|
||||
bundlePath == defaultBundlePath ? cachedFullCatalog : load(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
/// The plist read, and its one fallback: a bundle that won't open, a resource that isn't there,
|
||||
/// or a `"symbols"` key that isn't the dictionary this format has always used all read the same
|
||||
/// way — as "no inventory to read" — rather than as three different failure modes to chase. The
|
||||
/// merged curated set is never empty, so the picker always has *something* to search, even on a
|
||||
/// system whose metadata this reader cannot make sense of.
|
||||
private static func load(bundlePath: String) -> [String] {
|
||||
guard
|
||||
let bundle = Bundle(path: bundlePath),
|
||||
let plistPath = bundle.path(forResource: "name_availability", ofType: "plist"),
|
||||
let data = FileManager.default.contents(atPath: plistPath),
|
||||
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
|
||||
let root = plist as? [String: Any],
|
||||
let symbols = root["symbols"] as? [String: Any]
|
||||
else {
|
||||
return Set(defaultSet + CuratedSymbols.all).sorted()
|
||||
}
|
||||
return symbols.keys.sorted()
|
||||
}
|
||||
|
||||
/// `symbols` narrowed to the names matching `query` — pure, so the AND semantics and the
|
||||
/// order-preservation are assertable without a picker on screen.
|
||||
///
|
||||
/// Whitespace-trimmed first, and an empty result of that is "no query", not "match nothing" — a
|
||||
/// freshly opened search field must show the full catalog, not a blank grid. A non-empty query
|
||||
/// splits into whitespace-separated tokens, every one of which must appear, case-insensitively,
|
||||
/// somewhere in the name: `"wrench screw"` finds `wrench.and.screwdriver` the way a Spotlight-style
|
||||
/// search would, rather than requiring the words adjacent or in order.
|
||||
static func filter(_ query: String, in symbols: [String]) -> [String] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return symbols }
|
||||
let tokens = trimmed.split(whereSeparator: { $0.isWhitespace }).map { $0.lowercased() }
|
||||
return symbols.filter { name in
|
||||
let lowered = name.lowercased()
|
||||
return tokens.allSatisfy { lowered.contains($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
/// The picker's font-derived geometry — well side, well spacing, the fixed 6×6 grid, and the
|
||||
/// popover's own padding — following `StyleEditorLayout`'s derivation rather than restating it: the
|
||||
/// base well side and spacing are read straight off `StyleEditorLayout`'s statics, then the grid's
|
||||
/// wells and glyphs scale up by `gridScale` — a deliberate, user-tuned enlargement (the picker's grid
|
||||
/// is this popover's whole subject, where the style editor's is one section among several), still
|
||||
/// anchored to the shared base so the two components move together at every text size. The at-rest
|
||||
/// button keeps the unscaled side (`restSide`) — it sits inline with a text field and matches that
|
||||
/// field's height, not the grid's. Only the shape wraps a picker's own frame around them — six
|
||||
/// columns fixed (not a
|
||||
/// caller-configurable count, since a picker has no anchor-width story the way `StyleEditorLayout`'s
|
||||
/// sidebar/popover split does), and a total padded width that is fixed for the same reason the
|
||||
/// style editor's popover frame is: a popover is a window this app sizes, and a resizing one across
|
||||
/// keystrokes would be distracting rather than helpful.
|
||||
struct SymbolPickerLayout: Equatable {
|
||||
|
||||
static let columns = 6
|
||||
static let rows = 6
|
||||
/// 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
|
||||
|
||||
/// The at-rest button's side — the unscaled base, matched to the style editor's wells and to
|
||||
/// the text-field height the button sits beside.
|
||||
var restSide: CGFloat
|
||||
/// The glyph's own point size inside a grid well — the body size under `gridScale`, since a
|
||||
/// symbol renders at the font size, not the frame; a bigger well alone would just add margin.
|
||||
var glyphPointSize: CGFloat
|
||||
var wellSide: CGFloat
|
||||
var wellSpacing: CGFloat
|
||||
/// The gap between the search field and the grid below it — one figure rather than a pixel
|
||||
/// literal, so Dynamic Type moves it with everything else (10-accessibility.md's full-relative-
|
||||
/// scaling rule).
|
||||
var searchSpacing: CGFloat
|
||||
/// The popover's own inset, on all four sides.
|
||||
var contentPadding: CGFloat
|
||||
var gridWidth: CGFloat
|
||||
/// 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
|
||||
/// The grid's width plus its padding on both sides — the popover's fixed width.
|
||||
var popoverWidth: CGFloat
|
||||
|
||||
static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout {
|
||||
let baseSide = StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
|
||||
let side = (baseSide * gridScale).rounded()
|
||||
let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
|
||||
let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize)
|
||||
let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded()
|
||||
let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded()
|
||||
return SymbolPickerLayout(
|
||||
restSide: baseSide,
|
||||
glyphPointSize: (bodyPointSize * gridScale).rounded(),
|
||||
wellSide: side,
|
||||
wellSpacing: spacing,
|
||||
searchSpacing: spacing,
|
||||
contentPadding: padding,
|
||||
gridWidth: gridWidth,
|
||||
gridHeight: gridHeight,
|
||||
popoverWidth: (gridWidth + padding * 2).rounded()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The control
|
||||
|
||||
/// A single symbol well that opens a curated grid — the reusable primitive `03-board-ui.md`'s
|
||||
/// full-browser refusal ("the raw file is the escape hatch") leaves room for: not a new in-app way to
|
||||
/// hand-edit `icon`, but 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 for
|
||||
/// the board's own background+icon editor.
|
||||
///
|
||||
/// **View-local state only** — the popover's presented flag lives here, its search text lives with
|
||||
/// the popover content. Nothing about a store, an undo stack, or a target set is known to this type;
|
||||
/// `onSelect` is the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding
|
||||
/// would be.
|
||||
struct SymbolPicker: View {
|
||||
|
||||
/// The committed symbol name, or `nil` for "no override" — read alongside `fallback` rather than
|
||||
/// pre-resolved by the caller, so this view (and only this view) has to know the lenient-render
|
||||
/// rule (`ItemSymbol.name(_:fallback:)`'s rule, restated for a plain `String?` since a caller here
|
||||
/// may have no `FieldValue` at all).
|
||||
let current: String?
|
||||
/// The level default shown when `current` is absent or unresolvable, and the grid's leading well.
|
||||
let fallback: String
|
||||
/// The curated grid's contents. Defaults to `SymbolPickerCatalog.available` so a caller with no
|
||||
/// opinion gets the general-purpose set; a caller styling a specific domain (a template chooser,
|
||||
/// say) can supply its own.
|
||||
var symbols: [String] = SymbolPickerCatalog.available
|
||||
/// Whether the popover offers the search field and full-catalog fallback at all. `false` collapses
|
||||
/// the picker to the curated grid alone — a caller with no use for the OS's whole inventory
|
||||
/// (a fixed small vocabulary) is not forced to carry the search chrome anyway.
|
||||
var searchable: Bool = true
|
||||
/// The name to set, or `nil` to clear back to the default — mirrors `StyleChange`'s `set`/`remove`
|
||||
/// split without importing that type, since a caller outside the styling system has no `StyleChange`
|
||||
/// to hand back.
|
||||
let onSelect: (String?) -> Void
|
||||
|
||||
@State private var isPresented = false
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
/// What the well actually draws — `current` if this system can resolve it, `fallback` otherwise.
|
||||
/// The same lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated here
|
||||
/// because this control's `current` is already a plain optional string by the time it arrives.
|
||||
private var resolvedName: String {
|
||||
if let current, ItemSymbol.exists(current) { return current }
|
||||
return fallback
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
|
||||
Button {
|
||||
isPresented = true
|
||||
} label: {
|
||||
Image(systemName: resolvedName)
|
||||
.imageScale(.medium)
|
||||
.frame(width: layout.restSide, height: layout.restSide)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help("Symbol")
|
||||
.accessibilityLabel("Symbol")
|
||||
.accessibilityValue(resolvedName)
|
||||
.popover(isPresented: $isPresented, arrowEdge: .bottom) {
|
||||
SymbolPickerPopoverContent(
|
||||
current: current,
|
||||
fallback: fallback,
|
||||
symbols: symbols,
|
||||
searchable: searchable,
|
||||
layout: layout,
|
||||
onSelect: { name in
|
||||
onSelect(name)
|
||||
isPresented = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The popover's content
|
||||
|
||||
/// The popover's body: the search field (when `searchable`), and either the curated grid or a
|
||||
/// live search result — never both, since a query and the at-rest curated set answer the same
|
||||
/// question two different ways.
|
||||
private struct SymbolPickerPopoverContent: View {
|
||||
|
||||
let current: String?
|
||||
let fallback: String
|
||||
let symbols: [String]
|
||||
let searchable: Bool
|
||||
let layout: SymbolPickerLayout
|
||||
let onSelect: (String?) -> Void
|
||||
|
||||
@State private var query = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: layout.searchSpacing) {
|
||||
if searchable {
|
||||
searchField
|
||||
}
|
||||
resultBody
|
||||
}
|
||||
.padding(layout.contentPadding)
|
||||
.frame(width: layout.popoverWidth)
|
||||
}
|
||||
|
||||
private var searchField: some View {
|
||||
TextField("Search Symbols", text: $query)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
// **Escape steps outward one layer per press** (`BoardRenameField`'s idiom, the app's
|
||||
// standing Escape grammar): a non-empty query clears itself and keeps the popover open,
|
||||
// an empty one lets the press through to the popover's own dismissal.
|
||||
.onKeyPress(.escape) {
|
||||
guard !query.isEmpty else { return .ignored }
|
||||
query = ""
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var resultBody: some View {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
SymbolWellGrid(wells: curatedWells, layout: layout) { well in
|
||||
onSelect(well.isDefault ? nil : well.name)
|
||||
}
|
||||
} else {
|
||||
let matches = SymbolPickerCatalog.filter(query, in: SymbolPickerCatalog.fullCatalog())
|
||||
if matches.isEmpty {
|
||||
Text("No matches")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, layout.wellSpacing)
|
||||
} else {
|
||||
ScrollView(.vertical) {
|
||||
SymbolWellGrid(wells: matchWells(matches), layout: layout) { well in
|
||||
onSelect(well.name)
|
||||
}
|
||||
}
|
||||
.frame(height: layout.gridHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The at-rest grid: the leading default well, then up to 35 more from `symbols` — 03-board-ui.md
|
||||
/// § Styling ▸ Controls' "leading well is the level's default symbol" rule, restated for this
|
||||
/// control's plain-optional `current`/`fallback` pair.
|
||||
///
|
||||
/// `fallback` is dropped from the trailing set if present, so the default is never drawn twice —
|
||||
/// which is also why the trailing set is 35 rather than 36: the two together fill the 6×6 grid
|
||||
/// exactly when `fallback` was one of `symbols` to begin with (as it is for the card level, whose
|
||||
/// default `doc.text` sits inside `SymbolPickerCatalog.defaultSet`), and fall one well short of
|
||||
/// full when it wasn't (board and lane) — a quieter outcome than a grid that overflows its own
|
||||
/// 6×6 cap.
|
||||
private var curatedWells: [SymbolPickerWell] {
|
||||
let isDefaultSelected = current.map { !ItemSymbol.exists($0) } ?? true
|
||||
var wells = [SymbolPickerWell(
|
||||
id: 0,
|
||||
name: fallback,
|
||||
label: "Default (\(fallback))",
|
||||
isSelected: isDefaultSelected,
|
||||
isDefault: true
|
||||
)]
|
||||
let trailing = symbols.filter { $0 != fallback }
|
||||
for (index, name) in trailing.prefix(SymbolPickerLayout.columns * SymbolPickerLayout.rows - 1).enumerated() {
|
||||
wells.append(SymbolPickerWell(
|
||||
id: index + 1,
|
||||
name: name,
|
||||
label: name,
|
||||
isSelected: current == name,
|
||||
isDefault: false
|
||||
))
|
||||
}
|
||||
return wells
|
||||
}
|
||||
|
||||
private func matchWells(_ matches: [String]) -> [SymbolPickerWell] {
|
||||
matches.enumerated().map { index, name in
|
||||
SymbolPickerWell(id: index, name: name, label: name, isSelected: current == name, isDefault: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wells
|
||||
|
||||
/// One well in either grid: what it draws, what it is called, and whether it is the leading default.
|
||||
private struct SymbolPickerWell: Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let label: String
|
||||
let isSelected: Bool
|
||||
/// Whether this is the leading "no override" well — drawn quieter (`StyleWellFace`'s
|
||||
/// `.defaultSymbol` treatment) so "no symbol set" and "this symbol set" read differently at a
|
||||
/// glance, and selected by `onSelect(nil)` rather than `onSelect(well.name)`.
|
||||
let isDefault: Bool
|
||||
}
|
||||
|
||||
/// One well's face: the glyph, tinted by whether it is the default. `StyleEditor.swift`'s
|
||||
/// `StyleWellFace` already draws this exact shape, but as a `private` type it is not this file's to
|
||||
/// reach — a small sibling here, rather than widening that file's access for one caller outside it.
|
||||
private struct SymbolWellFace: View {
|
||||
|
||||
let name: String
|
||||
let isDefault: Bool
|
||||
let size: CGFloat
|
||||
/// The glyph's font size — set explicitly (`SymbolPickerLayout.glyphPointSize`) rather than
|
||||
/// inherited, since the grid's enlargement lives in the font, not the frame.
|
||||
let glyphPointSize: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
|
||||
.font(.system(size: glyphPointSize))
|
||||
.foregroundStyle(isDefault ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary))
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid — `StyleWellGrid`'s pattern,
|
||||
/// mirrored rather than shared for the same reason `SymbolWellFace` is its own type. The duplication
|
||||
/// is small (one `move(_:)` handler) and the alternative — exporting `StyleWellGrid` generically out
|
||||
/// of the style editor — would widen a file whose whole point is staying anchor-agnostic to a second,
|
||||
/// unrelated caller.
|
||||
private struct SymbolWellGrid: View {
|
||||
|
||||
let wells: [SymbolPickerWell]
|
||||
let layout: SymbolPickerLayout
|
||||
let onSelect: (SymbolPickerWell) -> Void
|
||||
|
||||
@FocusState private var focused: Int?
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
|
||||
count: SymbolPickerLayout.columns
|
||||
),
|
||||
spacing: layout.wellSpacing
|
||||
) {
|
||||
ForEach(wells) { well in
|
||||
Button {
|
||||
onSelect(well)
|
||||
} label: {
|
||||
SymbolWellFace(
|
||||
name: well.name,
|
||||
isDefault: well.isDefault,
|
||||
size: layout.wellSide,
|
||||
glyphPointSize: layout.glyphPointSize
|
||||
)
|
||||
.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: max(1, (layout.wellSide * 0.25).rounded()))
|
||||
.strokeBorder(
|
||||
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
|
||||
)
|
||||
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
|
||||
}
|
||||
|
||||
/// One step per press, clamped at the ends — `StyleWellGrid.move(_:)`'s rule, restated for this
|
||||
/// grid's own fixed column count.
|
||||
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
|
||||
let delta: Int
|
||||
switch key {
|
||||
case .leftArrow: delta = -1
|
||||
case .rightArrow: delta = 1
|
||||
case .upArrow: delta = -SymbolPickerLayout.columns
|
||||
case .downArrow: delta = SymbolPickerLayout.columns
|
||||
default: return .ignored
|
||||
}
|
||||
let current = focused ?? 0
|
||||
let next = min(max(0, current + delta), wells.count - 1)
|
||||
focused = next
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user