Files
lanework/Kanban/UI/PickerRect.swift
T
rzen f191e5c3ce Two widths, one height — the symbol picker squares to 5:4, colour holds 6:4, and None's slash follows the swatch out of the grid
The owner's follow-up review on the shipped rectangles (Pipeline card
5004c540): height was close, so it stays exactly 2.1em on both controls;
width now diverges by role via PickerRectMetrics.WidthRatio (color 1.5,
symbol 1.25) instead of one shared multiplier. The symbol glyph gets a
small em-derived inset back (SymbolGlyphControl.glyphInset) — a sliver of
breath, not the old padded ring — applied to the face rect before sizing
and fitting, still centred on the same midpoint. The collapsed colour
swatch reuses ColorSwatchNoneStrike's own geometry to draw the popover's
None slash whenever the stored value is nil or unresolvable, rather than
sitting empty. Both mounting anchors (the card sidebar's side-by-side
columns, the board popover beside the rename field) pick up .fixedSize()
so the controls render at their own intrinsic size instead of stretching
into whatever slack an HStack proposal leaves them.

KanbanTests/SymbolCatalogTests.swift and ColorSwatchPickerTests.swift
updated for the per-role widths, the shared-height claim, the glyph
inset rule, and the None-face predicate.
2026-08-09 21:37:29 -04:00

208 lines
9.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
/// **The shared single-zone rectangle** — the chrome behind both pickers (`SymbolGlyphControl` in
/// SymbolPicker.swift, `ColorSwatchControl` in ColorSwatchPicker.swift): a bordered, filled rounded
/// rect with exactly **one** hit zone, the whole of it. A click anywhere opens the control's popover.
///
/// ### The owner's reversal (2026-08-10, Pipeline card 5004c540, verbatim)
///
/// "lets revert to previous look of the symbol picker, no combo trigger. just a rectangle (slightly
/// oversized) clickable to show a popover with grid of symbols and colors (same popover that shows up
/// on click of combo trigger). lets do same for color..."
///
/// This retires the two-zone combo chrome this file used to hold (`ComboFieldControl`/
/// `ComboFieldMetrics`, the 2026-08-09 "the two pickers should rhyme" pass): the face/trigger split,
/// the `NSPopUpButton`-style chevron square at the trailing edge, and the face zone's own door onto a
/// *standalone* picker independent of the popover. What survives, because it was never about the
/// trigger: the bordered rounded-rect field (`drawField`), the disabled treatment (a single
/// transparency layer under both), and — because a popover is still the one way into a standalone
/// picker (`SymbolBrowserPanel`'s **More Symbols…** row, the new colour popover's **Other…** row) —
/// the `.popUpButton` accessibility role and the value text a coordinator hands over.
///
/// ### Sizing (the same ruling): "50% taller... and about 4:6 ratio of height to width"
///
/// `height` is **2.1 em** — 50% over the two-zone chrome's last-shipped 1.4 em (18pt → 27pt at the
/// standard 13pt body). `width` is `height` **times a per-role ratio**, stated as a multiplier on
/// `height` rather than as its own independent em figure, so each ratio holds exactly (to rounding) at
/// every body size instead of two multiples that could drift apart the way `ComboFieldMetrics`'
/// `height` and `width` once did (that file's own retired header told that story). 10-accessibility.md's
/// full-relative-scaling rule, unchanged: every figure is a multiple of the body point size.
///
/// ### Two widths, one height (the owner's 2026-08-10 follow-up, verbatim)
///
/// "width to height ratio should be closer to 6:4 [for color] ... the symbol picker should be even
/// more square at ratio of 5:4 or so ... the height for the two pickers should be exactly the same."
/// `height` stays a single shared figure — nothing here lets it diverge between the two controls —
/// while `width` becomes a function of which picker is asking, via `WidthRatio`. The ratio lives as a
/// multiplier rather than two hand-rounded em widths for the reason above: two independent em figures
/// can drift apart a rounding step at a time as the body size changes, the exact failure this file's
/// own `height`/`width` split was already written to avoid for the first ratio.
struct PickerRectMetrics: Equatable {
/// The control's height — 2.1 em, 27pt at the standard 13pt body. **Identical between the two
/// pickers by the owner's own ruling**; nothing in this type lets a caller diverge it by role.
var height: CGFloat
/// The control's width — `height × widthRatio`, so the requested ratio is exact rather than
/// approximated by two independently rounded em figures.
var width: CGFloat
/// The rectangle's own radius, and the field's — a point apart so the two rounded rects run
/// concentric. Unchanged from the two-zone chrome's own figures; nothing about the trigger's
/// removal touches these.
var cornerRadius: CGFloat
var fieldRadius: CGFloat
/// The width:height multiplier a picker asks for — a closed set of two named cases (not a bare
/// `CGFloat` parameter) so a call site reads "the colour picker's ratio" rather than a literal
/// `1.5` a reader has to trust is still current.
enum WidthRatio: CGFloat, Equatable {
/// The colour rectangle — "closer to 6:4" (width:height), the owner's 2026-08-10 follow-up.
case color = 1.5
/// The symbol rectangle — "even more square... ratio of 5:4 or so", the same follow-up.
case symbol = 1.25
}
static func metrics(bodyPointSize: CGFloat, widthRatio: WidthRatio) -> PickerRectMetrics {
func em(_ multiple: CGFloat) -> CGFloat { max(1, (bodyPointSize * multiple).rounded()) }
let height = em(2.1)
let width = max(1, (height * widthRatio.rawValue).rounded())
return PickerRectMetrics(
height: height,
width: width,
cornerRadius: em(0.23),
fieldRadius: em(0.31)
)
}
/// The live metrics for one picker's role — read from the same place every other font-derived
/// geometry in the app reads it (`CardWindowMetrics.bodyPointSize`), so a text-size change moves
/// both rectangles, and both stay the same height, with everything else.
@MainActor
static func current(_ widthRatio: WidthRatio) -> PickerRectMetrics {
metrics(bodyPointSize: CardWindowMetrics.bodyPointSize, widthRatio: widthRatio)
}
}
// MARK: - The control
/// The shared rectangle. Subclasses draw whatever fills it; nothing else here is theirs to change.
///
/// Plain internal rather than `private`: both subclasses live in other files, and each is an
/// `NSViewRepresentable`'s `NSViewType`, an associated-type witness the compiler requires to be at
/// least as visible as the representable itself.
class PickerRectControl: NSControl {
/// The geometry every pass draws from, refreshed by the representable on each SwiftUI update so
/// a text-size change lands without recreating the view. The placeholder ratio here is arbitrary
/// — every real subclass instance gets its own role's metrics on the first `updateNSView` before
/// this default is ever drawn or measured.
var metrics: PickerRectMetrics = .metrics(bodyPointSize: 13, widthRatio: .color) {
didSet {
guard metrics != oldValue else { return }
invalidateIntrinsicContentSize()
needsDisplay = true
}
}
/// The one click this control answers — anywhere in its bounds, since there is only the one zone
/// now. Opens whichever popover the caller wired.
///
/// Unannotated rather than `@MainActor`: this class is an `NSResponder` subclass and therefore
/// already main-actor isolated, so every call site is on the main actor by construction.
var onClick: (() -> Void)?
/// What VoiceOver reads as this control's value: the resolved symbol's name, the swatch's stored
/// value. Set by whichever coordinator owns the control.
var accessibilityValueText: String?
override var isEnabled: Bool {
get { super.isEnabled }
set {
super.isEnabled = newValue
needsDisplay = true
}
}
override var intrinsicContentSize: NSSize {
NSSize(width: metrics.width, height: metrics.height)
}
// MARK: Drawing
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
defer { context.restoreGState() }
// A transparency layer, not a flat `setAlpha` around each shape: the face's underlay, fill
// and stroke overlap, and drawing each at reduced alpha independently would let the stroke
// double up over the fill beneath it.
if !isEnabled {
context.setAlpha(0.35)
context.beginTransparencyLayer(auxiliaryInfo: nil)
}
drawField()
drawFace(in: bounds)
if !isEnabled {
context.endTransparencyLayer()
}
}
/// The subclass's half: whatever fills the whole rectangle. The base draws nothing. Handed the
/// control's own `bounds` — there is no zone to carve out of it any more.
func drawFace(in rect: NSRect) {}
/// The control's own field: a bordered, filled rounded rect over the whole bounds, under the
/// face — `controlColor` fill, `separatorColor` hairline, the half-point inset keeping the
/// stroke on whole pixels. Unchanged from the two-zone chrome.
private func drawField() {
let path = NSBezierPath(
roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
xRadius: metrics.fieldRadius,
yRadius: metrics.fieldRadius
)
NSColor.controlColor.setFill()
path.fill()
NSColor.separatorColor.setStroke()
path.lineWidth = 1
path.stroke()
}
// MARK: Events
/// Anywhere in the control, full stop — there is only the one zone now.
override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
onClick?()
}
override var acceptsFirstResponder: Bool { isEnabled }
/// Space and Return open the popover — the one keyboard path into this control, the same keys
/// the two-zone chrome's trigger answered.
override func keyDown(with event: NSEvent) {
guard isEnabled else {
super.keyDown(with: event)
return
}
switch event.keyCode {
case 49, 36, 76: // Space, Return, keypad Enter
onClick?()
default:
super.keyDown(with: event)
}
}
// MARK: Accessibility
/// `.popUpButton` — unchanged from the two-zone chrome: this control still opens a set of
/// choices, it just does so from one hit zone instead of two.
override func accessibilityRole() -> NSAccessibility.Role? { .popUpButton }
override func accessibilityValue() -> Any? { accessibilityValueText }
override func accessibilityPerformPress() -> Bool {
guard isEnabled else { return false }
onClick?()
return true
}
}