Files
lanework/Kanban/UI/PickerRect.swift
T
rzen 4210bbe0a7 The combo's trigger retires — each picker is one rectangle again, taller, and Style becomes Appearance in the sidebar
Both the symbol picker and the background color control give up the two-zone combo chrome from the 2026-08-09 rework: no more face/trigger split, no more trailing chevron square. Each is now a single bordered rectangle with one hit zone, 2.1em tall and 1.5x that wide (a 4:6 ratio, 50% taller than the retired chrome's 18pt). A click anywhere opens the same curated popover the trigger used to gate.

The background rectangle's popover is new: a None-plus-sixteen palette grid mirroring the Style… popover's own wells, with an Other… row onto the shared, pre-debounced Colors panel session — replacing the old NSMenu dropdown outright. The symbol rectangle keeps its existing popover (search, curated grid, tint colors, More Symbols…) verbatim; only its entry point collapsed to one zone.

ComboFieldControl/ComboFieldMetrics (ComboField.swift) are replaced by PickerRectControl/PickerRectMetrics (PickerRect.swift). ColorComboView and its NSMenu-building pure model are retired wholesale in favor of ColorSwatchPicker. SymbolComboControl becomes SymbolGlyphControl. PaletteSwatch.rectImage, the last caller of which was the retired dropdown's menu rows, goes with it.

In the card window sidebar, the "Style" section header becomes "Appearance" (sidebar only — the board context menu's Style… item and StyleEditorView's own naming are untouched), and the symbol and background controls move from stacked rows to side-by-side columns, each captioned above rather than leading. CardStyleSection no longer carries its own debounce Task for background panel picks — the shared Colors-panel session now delivers an already-settled value.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 20:44:47 -04:00

183 lines
7.9 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 1.5** — the 4:6 ratio stated as a multiplier on
/// `height` rather than as its own independent em figure, so the 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.
struct PickerRectMetrics: Equatable {
/// The control's height — 2.1 em, 27pt at the standard 13pt body.
var height: CGFloat
/// The control's width — `height × 1.5`, so the 4:6 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
static func metrics(bodyPointSize: CGFloat) -> PickerRectMetrics {
func em(_ multiple: CGFloat) -> CGFloat { max(1, (bodyPointSize * multiple).rounded()) }
let height = em(2.1)
let width = max(1, (height * 1.5).rounded())
return PickerRectMetrics(
height: height,
width: width,
cornerRadius: em(0.23),
fieldRadius: em(0.31)
)
}
/// The live metrics — 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 with
/// everything else.
@MainActor
static var current: PickerRectMetrics { metrics(bodyPointSize: CardWindowMetrics.bodyPointSize) }
}
// 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.
var metrics: PickerRectMetrics = .metrics(bodyPointSize: 13) {
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
}
}