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
This commit is contained in:
@@ -1,197 +0,0 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `ColorComboView`'s pure model (`ColorCombo.swift`): item-list composition, selection matching,
|
||||
/// hex normalization and display-name casing — every rule stated where a test can hold it without
|
||||
/// an `NSView`, matching `PaletteTests.swift`'s own split between the vocabulary and the view.
|
||||
///
|
||||
/// One outer suite, nested by concern — swift-testing discovers nested types as sub-suites, which
|
||||
/// is what lets `-only-testing:KanbanTests/ColorComboTests` run the whole file while each concern
|
||||
/// still reads as its own group, `PaletteTests.swift`'s top-level-structs style scoped one level in.
|
||||
struct ColorComboTests {
|
||||
|
||||
// MARK: - Display names
|
||||
|
||||
struct DisplayName {
|
||||
|
||||
@Test func kebabCaseBecomesTitleCaseWithHyphensAsSpaces() {
|
||||
#expect(ColorComboModel.displayName("light-cayenne") == "Light Cayenne")
|
||||
#expect(ColorComboModel.displayName("smokey-rich-eggplant") == "Smokey Rich Eggplant")
|
||||
#expect(ColorComboModel.displayName("obsidian") == "Obsidian")
|
||||
#expect(ColorComboModel.displayName("deep-sky-blue") == "Deep Sky Blue")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex normalization
|
||||
|
||||
struct HexNormalization {
|
||||
|
||||
@Test func sixDigitHexUppercasesUnchanged() {
|
||||
#expect(ColorComboModel.normalizedHex("#b6071e") == "#B6071E")
|
||||
#expect(ColorComboModel.normalizedHex("#B6071E") == "#B6071E")
|
||||
}
|
||||
|
||||
/// Alpha below full opacity is meaningful and stays — only a fully-opaque suffix collapses.
|
||||
@Test func eightDigitHexWithPartialAlphaStaysEightDigits() {
|
||||
#expect(ColorComboModel.normalizedHex("#b6071e80") == "#B6071E80")
|
||||
}
|
||||
|
||||
/// `#RRGGBBFF` — fully opaque, spelled with an explicit alpha byte — collapses to the
|
||||
/// six-digit form, so it compares equal to a bare `#RRGGBB` written for the same colour.
|
||||
@Test func fullyOpaqueEightDigitHexCollapsesToSixDigits() {
|
||||
#expect(ColorComboModel.normalizedHex("#b6071eFF") == "#B6071E")
|
||||
#expect(ColorComboModel.normalizedHex("#B6071EFF") == ColorComboModel.normalizedHex("#B6071E"))
|
||||
}
|
||||
|
||||
@Test func malformedOrUnprefixedValuesNormalizeToNil() {
|
||||
for value in ["", "#", "#12", "#12345", "#1234567", "#GGGGGG", "B6071E", "light-cayenne"] {
|
||||
#expect(ColorComboModel.normalizedHex(value) == nil, "'\(value)' should not normalize")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection matching
|
||||
|
||||
struct Matching {
|
||||
|
||||
@Test func nilValueMatchesNone() {
|
||||
#expect(ColorComboModel.match(role: .background, value: nil) == .none)
|
||||
#expect(ColorComboModel.match(role: .foreground, value: nil) == .none)
|
||||
}
|
||||
|
||||
@Test func aNameInTheRolesOwnPaletteMatchesThatRowExactly() {
|
||||
#expect(ColorComboModel.match(role: .background, value: "light-cayenne") == .palette("light-cayenne"))
|
||||
#expect(ColorComboModel.match(role: .foreground, value: "fern") == .palette("fern"))
|
||||
}
|
||||
|
||||
/// Names are matched exactly, like `Palette.nsColor(for:)` — a near-miss is not "the same
|
||||
/// row", it is an off-palette value with its own dynamic row.
|
||||
@Test func aNearMissNameIsNotAPaletteMatch() {
|
||||
let match = ColorComboModel.match(role: .background, value: "Light-Cayenne")
|
||||
guard case let .current(_, title) = match else {
|
||||
Issue.record("expected .current, got \(match)")
|
||||
return
|
||||
}
|
||||
#expect(title == "Light-Cayenne")
|
||||
}
|
||||
|
||||
/// A hex that normalizes to one of the role's own palette hexes selects the **name**, not
|
||||
/// the hex — case-insensitively, and with a fully-opaque `#RRGGBBFF` collapsing exactly like
|
||||
/// a bare `#RRGGBB` would.
|
||||
@Test func aHexEqualToAPaletteColorsHexMatchesItsNamedRow() {
|
||||
#expect(ColorComboModel.match(role: .background, value: "#B6071E") == .palette("light-cayenne"))
|
||||
#expect(ColorComboModel.match(role: .background, value: "#b6071e") == .palette("light-cayenne"))
|
||||
#expect(ColorComboModel.match(role: .background, value: "#B6071EFF") == .palette("light-cayenne"))
|
||||
#expect(ColorComboModel.match(role: .background, value: "#b6071eff") == .palette("light-cayenne"))
|
||||
}
|
||||
|
||||
/// Partial alpha keeps a value off the palette rows even when its RGB matches one exactly —
|
||||
/// the stored colour is genuinely translucent, which no palette entry is.
|
||||
@Test func aTranslucentHexNeverMatchesAnOpaquePaletteColor() {
|
||||
let match = ColorComboModel.match(role: .background, value: "#B6071E80")
|
||||
guard case let .current(swatchValue, _) = match else {
|
||||
Issue.record("expected .current, got \(match)")
|
||||
return
|
||||
}
|
||||
#expect(swatchValue == "#B6071E80")
|
||||
}
|
||||
|
||||
/// A name from the *other* picker's table — `carnation` is foreground-only — is not one of
|
||||
/// `.background`'s own table, so it falls to the dynamic row, titled with its own display name
|
||||
/// since the other table does know it.
|
||||
@Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() {
|
||||
let match = ColorComboModel.match(role: .background, value: "carnation")
|
||||
#expect(match == .current(swatchValue: "carnation", title: "Carnation"))
|
||||
}
|
||||
|
||||
/// A custom hex nowhere in either table: the dynamic row states it verbatim, uppercased.
|
||||
@Test func aCustomHexFallsToTheDynamicRowUppercased() {
|
||||
let match = ColorComboModel.match(role: .background, value: "#123456")
|
||||
#expect(match == .current(swatchValue: "#123456", title: "#123456"))
|
||||
let lowercase = ColorComboModel.match(role: .background, value: "#abcdef")
|
||||
#expect(lowercase == .current(swatchValue: "#abcdef", title: "#ABCDEF"))
|
||||
}
|
||||
|
||||
/// Unresolvable garbage — neither a name either table knows nor a parseable hex — falls to
|
||||
/// the dynamic row exactly as written, no casing applied.
|
||||
@Test func garbageFallsToTheDynamicRowVerbatim() {
|
||||
let match = ColorComboModel.match(role: .background, value: "chartreuse")
|
||||
#expect(match == .current(swatchValue: "chartreuse", title: "chartreuse"))
|
||||
}
|
||||
|
||||
/// The three names shared by both tables (`obsidian`, `aluminum`, `chalk`) are in *both*
|
||||
/// roles' own palettes, so they match directly and never reach the "foreign name" branch.
|
||||
@Test func namesSharedByBothTablesMatchDirectlyInEitherRole() {
|
||||
#expect(ColorComboModel.match(role: .background, value: "obsidian") == .palette("obsidian"))
|
||||
#expect(ColorComboModel.match(role: .foreground, value: "obsidian") == .palette("obsidian"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item list composition
|
||||
|
||||
struct Menu {
|
||||
|
||||
/// None first, a separator, then exactly the role's own entries, in the palette's own order.
|
||||
@Test func baseOrderIsNoneSeparatorThenTheRolesTwelve() {
|
||||
let menu = ColorComboModel.menu(role: .background, value: nil)
|
||||
var expected: [ColorComboItem] = [.none, .separator]
|
||||
expected.append(contentsOf: Palette.backgrounds.map { .palette($0.name) })
|
||||
expected.append(contentsOf: [.separator, .other])
|
||||
#expect(menu.items == expected)
|
||||
}
|
||||
|
||||
@Test func foregroundRoleListsTheForegroundTwelveNotTheBackgroundTwelve() {
|
||||
let menu = ColorComboModel.menu(role: .foreground, value: nil)
|
||||
let paletteNames = menu.items.compactMap { item -> String? in
|
||||
if case let .palette(name) = item { return name }
|
||||
return nil
|
||||
}
|
||||
#expect(paletteNames == Palette.foregrounds.map(\.name))
|
||||
}
|
||||
|
||||
/// `Other…` is always last, and there is never more than one dynamic row.
|
||||
@Test func otherIsAlwaysLast() {
|
||||
for value in [nil, "obsidian", "carnation", "#123456", "chartreuse"] {
|
||||
let menu = ColorComboModel.menu(role: .background, value: value)
|
||||
#expect(menu.items.last == .other)
|
||||
}
|
||||
}
|
||||
|
||||
/// No dynamic row, and no selected item beyond the palette rows, when the value is `nil` or
|
||||
/// one of the role's own entries.
|
||||
@Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() {
|
||||
let none = ColorComboModel.menu(role: .background, value: nil)
|
||||
#expect(!none.items.contains { if case .current = $0 { return true }; return false })
|
||||
#expect(none.selectedIndex == 0)
|
||||
#expect(none.items[none.selectedIndex] == .none)
|
||||
|
||||
let named = ColorComboModel.menu(role: .background, value: "dark-teal")
|
||||
#expect(!named.items.contains { if case .current = $0 { return true }; return false })
|
||||
#expect(named.items[named.selectedIndex] == .palette("dark-teal"))
|
||||
}
|
||||
|
||||
/// A foreign or unresolvable value inserts exactly one dynamic row, immediately before the
|
||||
/// trailing separator and `Other…`, and it is the checked row.
|
||||
@Test func dynamicRowAppearsOnlyForAForeignValueAndIsSelected() {
|
||||
let menu = ColorComboModel.menu(role: .background, value: "#123456")
|
||||
let dynamicRows = menu.items.filter { if case .current = $0 { return true }; return false }
|
||||
#expect(dynamicRows.count == 1)
|
||||
#expect(menu.items[menu.selectedIndex] == .current(swatchValue: "#123456", title: "#123456"))
|
||||
// Immediately before the trailing separator + Other…
|
||||
#expect(
|
||||
Array(menu.items.suffix(3)) ==
|
||||
[.current(swatchValue: "#123456", title: "#123456"), .separator, .other]
|
||||
)
|
||||
}
|
||||
|
||||
/// A hex landing exactly on a palette colour selects that named row and adds no dynamic row
|
||||
/// at all — the same shape as picking the name directly.
|
||||
@Test func anExactHexMatchProducesNoDynamicRow() {
|
||||
let byName = ColorComboModel.menu(role: .background, value: "light-cayenne")
|
||||
let byHex = ColorComboModel.menu(role: .background, value: "#B6071E")
|
||||
#expect(byName.items == byHex.items)
|
||||
#expect(byName.selectedIndex == byHex.selectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **`ColorSwatchPicker`'s pure pieces** (`ColorSwatchPicker.swift`), the 2026-08-10 rework that
|
||||
/// retired `ColorComboView`'s `NSMenu` dropdown and its pure model — `ColorComboItem`, `ColorComboMatch`,
|
||||
/// `ColorComboMenu`, `ColorComboModel.match`/`.menu`/`.displayName`/`.normalizedHex` — wholesale. This
|
||||
/// file used to hold that model's tests; nothing in it survives the dropdown's removal, so it now
|
||||
/// tests what replaced it: which palette a role offers, and the popover's own grid geometry.
|
||||
|
||||
// MARK: - Role
|
||||
|
||||
@Suite("ColorSwatchRole ▸ which palette")
|
||||
struct ColorSwatchRoleTests {
|
||||
|
||||
@Test("Background lists the backgrounds table, foreground the foregrounds table")
|
||||
func rolesListTheirOwnTable() {
|
||||
#expect(ColorSwatchRole.background.palette.map(\.name) == Palette.backgrounds.map(\.name))
|
||||
#expect(ColorSwatchRole.foreground.palette.map(\.name) == Palette.foregrounds.map(\.name))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Popover geometry
|
||||
|
||||
/// **`SymbolPickerLayout`'s pattern, restated for a plain colour grid.** The layout is pure and
|
||||
/// font-derived (10-accessibility.md's full-relative-scaling rule), so every claim below is
|
||||
/// assertable without a popover on screen.
|
||||
@Suite("ColorSwatchPopoverLayout ▸ geometry")
|
||||
struct ColorSwatchPopoverLayoutTests {
|
||||
|
||||
/// How wide `columns` wells and the gaps between them actually draw, at a given text size —
|
||||
/// `StyleEditorLayoutTests.gridWidth`'s own helper, restated for this layout's fixed seven.
|
||||
private func gridWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
let columns = ColorSwatchPopoverLayout.columns
|
||||
let layout = ColorSwatchPopoverLayout.metrics(bodyPointSize: bodyPointSize)
|
||||
return CGFloat(columns) * layout.wellSide + CGFloat(columns - 1) * layout.wellSpacing
|
||||
}
|
||||
|
||||
@Test("Seven columns — None + sixteen wells fall as 7 + 7 + 3, StyleEditorLayout.popover's own settled fit")
|
||||
func columnsAreSeven() {
|
||||
#expect(ColorSwatchPopoverLayout.columns == 7)
|
||||
// None + Palette.backgrounds (16) is 17 wells; seven columns is the count that keeps the
|
||||
// last row from overflowing to four rows or wasting a nearly-empty one.
|
||||
let wellCount = 1 + Palette.backgrounds.count
|
||||
let rows = (wellCount + ColorSwatchPopoverLayout.columns - 1) / ColorSwatchPopoverLayout.columns
|
||||
#expect(rows == 3)
|
||||
}
|
||||
|
||||
@Test("The grid's well side matches the symbol popover's own scale-up over the shared base")
|
||||
func wellSideMatchesTheSymbolPopoversScale() {
|
||||
for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] {
|
||||
let color = ColorSwatchPopoverLayout.metrics(bodyPointSize: size)
|
||||
let symbol = SymbolPickerLayout.metrics(bodyPointSize: size)
|
||||
// Both scale `StyleEditorLayout.wellSide` by the identical `gridScale` — the two
|
||||
// popovers introduced together (2026-08-10) read at one scale.
|
||||
#expect(color.wellSide == symbol.wellSide, "the two popovers' wells drifted apart at \(size)pt")
|
||||
#expect(ColorSwatchPopoverLayout.gridScale == SymbolPickerLayout.gridScale)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The popover's frame grows with the text, and its wells keep fitting inside it")
|
||||
func popoverScalesAndFits() {
|
||||
var previousWidth: CGFloat = 0
|
||||
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
|
||||
let layout = ColorSwatchPopoverLayout.metrics(bodyPointSize: size)
|
||||
#expect(layout.popoverWidth > previousWidth, "the popover must widen with the text at \(size)pt")
|
||||
previousWidth = layout.popoverWidth
|
||||
|
||||
let content = layout.popoverWidth - 2 * layout.contentPadding
|
||||
#expect(gridWidth(bodyPointSize: size) <= content, "the grid overflows its popover at \(size)pt")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The popover's settled geometry at the standard text size")
|
||||
func settledGeometryAtTheStandardBody() {
|
||||
let layout = ColorSwatchPopoverLayout.metrics(bodyPointSize: 13)
|
||||
// `StyleEditorLayout`'s own statics at 13pt: wellSpacing 6, sectionSpacing 14. Well side is
|
||||
// the style editor's own 20pt scaled by `gridScale`, computed the identical way the
|
||||
// implementation does rather than restated as a literal — `20 * 1.3` is not exactly
|
||||
// representable in binary floating point, and comparing two independently-rounded literals
|
||||
// is exactly the kind of thing that can drift a bit apart from the real computation.
|
||||
#expect(layout.wellSpacing == 6)
|
||||
#expect(layout.contentPadding == 14)
|
||||
let expectedSide = (StyleEditorLayout.wellSide(bodyPointSize: 13) * ColorSwatchPopoverLayout.gridScale).rounded()
|
||||
#expect(layout.wellSide == expectedSide)
|
||||
#expect(layout.wellSide == 26)
|
||||
#expect(layout.gridWidth == layout.wellSide * 7 + layout.wellSpacing * 6)
|
||||
#expect(layout.popoverWidth == layout.gridWidth + layout.contentPadding * 2)
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,9 @@ struct CustomColorCodecTests {
|
||||
|
||||
/// **Full opacity collapses to six digits.** A colour the user never touched the opacity slider
|
||||
/// on has to be written exactly as a curated palette entry would be, or it would never match one
|
||||
/// (`ColorComboModel.match` compares normalized strings) and a panel pick that landed dead on
|
||||
/// `fern` would store an anonymous hex instead of the name.
|
||||
/// (`Palette.name(forHex:in:)`'s case-insensitive compare, `SystemColorPanel.changeColor(_:)`'s
|
||||
/// own rule) and a panel pick that landed dead on `fern` would store an anonymous hex instead of
|
||||
/// the name.
|
||||
@Test("Opacity is written only when there is some")
|
||||
func alphaCollapsesAtFullOpacity() throws {
|
||||
let opaque = NSColor(srgbRed: 0.5, green: 0.25, blue: 0.75, alpha: 1)
|
||||
|
||||
@@ -47,9 +47,10 @@ struct PaletteTableTests {
|
||||
}
|
||||
|
||||
/// Every hex is spelled the one way the app emits them — uppercase `#RRGGBB` — so a panel pick
|
||||
/// that lands on a palette colour matches by string (`ColorComboModel.match`'s hex branch) and
|
||||
/// comes back as the *name*. A lowercase entry would still resolve and would still round-trip;
|
||||
/// it would just quietly stop being recognised as the palette colour it is.
|
||||
/// that lands on a palette colour matches by string (`Palette.name(forHex:in:)`, `SystemColorPanel.
|
||||
/// changeColor(_:)`'s own case-insensitive compare) and comes back as the *name*. A lowercase
|
||||
/// entry would still resolve and would still round-trip; it would just quietly stop being
|
||||
/// recognised as the palette colour it is.
|
||||
@Test func everyHexIsSixUppercaseDigits() {
|
||||
for entry in Palette.foregrounds + Palette.backgrounds {
|
||||
#expect(entry.hex == entry.hex.uppercased(), "'\(entry.name)' is not uppercase")
|
||||
|
||||
@@ -212,55 +212,50 @@ struct SymbolCatalogSearchTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The shared combo chrome
|
||||
// MARK: - The shared rectangle chrome
|
||||
|
||||
/// **The rhyme, asserted.** The card's first ask was that the two pickers be "roughly same
|
||||
/// shape/size", and the way that was made true is structural — one metrics value, one base control —
|
||||
/// so the test is about the structure rather than about two numbers that happen to agree today.
|
||||
@Suite("ComboField ▸ the shared chrome")
|
||||
struct ComboFieldMetricsTests {
|
||||
/// **The rhyme, asserted.** The owner's 2026-08-10 ruling asked for both pickers to become "a
|
||||
/// rectangle (slightly oversized)... about 4:6 ratio of height to width" — and the way that is made
|
||||
/// true is structural — one metrics value, one base control — so the test is about the structure
|
||||
/// rather than about two numbers that happen to agree today.
|
||||
@Suite("PickerRect ▸ the shared chrome")
|
||||
struct PickerRectMetricsTests {
|
||||
|
||||
@Test("Every figure scales with the body font, and reproduces the shipped numbers at 13pt")
|
||||
func metricsAtTheStandardBody() {
|
||||
let metrics = ComboFieldMetrics.metrics(bodyPointSize: 13)
|
||||
// Evening 2026-08-09 review: "reduce vertical size of both pickers by 50%" — height is half
|
||||
// the 4:5 pass's 36, and width holds the 4:5 pass's own pixel figure instead of re-deriving
|
||||
// from the now-shorter height (`ComboFieldMetrics`'s own doc comment).
|
||||
#expect(metrics.height == 18)
|
||||
#expect(metrics.width == 29)
|
||||
#expect(metrics.triggerWidth == 16)
|
||||
let metrics = PickerRectMetrics.metrics(bodyPointSize: 13)
|
||||
// Owner's ruling, 2026-08-10: "50% taller" than the retired two-zone chrome's 18pt (→ 27pt),
|
||||
// and "about 4:6 ratio" — width is height × 1.5 (`PickerRectMetrics`'s own doc comment).
|
||||
#expect(metrics.height == 27)
|
||||
#expect(metrics.width == 41)
|
||||
#expect(metrics.cornerRadius == 3)
|
||||
#expect(metrics.fieldRadius == 4)
|
||||
#expect(metrics.triggerInset == 2)
|
||||
}
|
||||
|
||||
@Test("A larger text size grows every figure, and none collapses to zero")
|
||||
func metricsScale() {
|
||||
let small = ComboFieldMetrics.metrics(bodyPointSize: 11)
|
||||
let large = ComboFieldMetrics.metrics(bodyPointSize: 24)
|
||||
let small = PickerRectMetrics.metrics(bodyPointSize: 11)
|
||||
let large = PickerRectMetrics.metrics(bodyPointSize: 24)
|
||||
#expect(large.height > small.height)
|
||||
#expect(large.width > small.width)
|
||||
#expect(large.triggerWidth > small.triggerWidth)
|
||||
for metrics in [ComboFieldMetrics.metrics(bodyPointSize: 8), small, large] {
|
||||
for metrics in [PickerRectMetrics.metrics(bodyPointSize: 8), small, large] {
|
||||
#expect(metrics.height >= 1)
|
||||
#expect(metrics.width >= 1)
|
||||
#expect(metrics.triggerWidth >= 1)
|
||||
#expect(metrics.cornerRadius >= 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The owner's evening figures, held still.** "Reduce vertical size of both pickers by 50%"
|
||||
/// (2026-08-09 evening) superseded the earlier "almost square, about 4:5 ratio" ruling outright —
|
||||
/// `height` and `width` are now independent em figures rather than one derived from the other, so
|
||||
/// this asserts both hold at every body size the superseded ratio test used, and that `faceWidth`
|
||||
/// never collapses the way it would have if `width` had stayed a function of `height`.
|
||||
@Test("Height is 1.4 em, width is 2.2 em, and the face zone stays positive at every body size")
|
||||
func heightAndWidthAreIndependentEmFigures() {
|
||||
/// **The owner's 2026-08-10 figures, pinned.** Height is 2.1 em; width is derived from height
|
||||
/// (`height × 1.5`) rather than its own independent em multiple, which is what keeps the 4:6
|
||||
/// ratio exact — to rounding — at every body size instead of the two figures drifting apart.
|
||||
@Test("Height is 2.1 em, width is 1.5 × height, at every body size")
|
||||
func heightAndWidthHoldTheRatio() {
|
||||
for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] {
|
||||
let metrics = ComboFieldMetrics.metrics(bodyPointSize: size)
|
||||
#expect(metrics.height == max(1, (size * 1.4).rounded()), "height drifted from 1.4 em at \(size)pt")
|
||||
#expect(metrics.width == max(1, (size * 2.2).rounded()), "width drifted from 2.2 em at \(size)pt")
|
||||
#expect(metrics.faceWidth > 0, "the face zone collapsed at \(size)pt")
|
||||
let metrics = PickerRectMetrics.metrics(bodyPointSize: size)
|
||||
#expect(metrics.height == max(1, (size * 2.1).rounded()), "height drifted from 2.1 em at \(size)pt")
|
||||
#expect(metrics.width == max(1, (metrics.height * 1.5).rounded()), "width drifted from 1.5 × height at \(size)pt")
|
||||
// 4:6 as a ratio, within the slack one rounding step introduces at the smallest sizes.
|
||||
#expect(abs(metrics.width / metrics.height - 1.5) < 0.06, "ratio drifted from 4:6 at \(size)pt")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,80 +264,57 @@ struct ComboFieldMetricsTests {
|
||||
@Test("The field's radius stays outside the face's")
|
||||
func radiiAreConcentric() {
|
||||
for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] {
|
||||
let metrics = ComboFieldMetrics.metrics(bodyPointSize: size)
|
||||
let metrics = PickerRectMetrics.metrics(bodyPointSize: size)
|
||||
#expect(metrics.fieldRadius >= metrics.cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The new actual-rect rule.** `ComboFieldMetrics.glyphPointSize` is gone; `SymbolComboControl`
|
||||
/// now sizes a glyph off whichever face rect it is actually handed at draw time (this is what lets
|
||||
/// the sidebar's stretched-wide control still read as "no padding" rather than a small glyph in a
|
||||
/// big field). Both the point-size rule and the overshoot-scaling rule are pure static functions
|
||||
/// on `SymbolComboControl`, assertable with no control on screen and no draw.
|
||||
/// **The actual-rect rule, unaffected by the trigger's removal.** `SymbolGlyphControl` sizes a
|
||||
/// glyph off whichever face rect it is actually handed at draw time — now always the control's
|
||||
/// own `bounds`, since there is no trigger strip left to subtract. Both the point-size rule and
|
||||
/// the overshoot-scaling rule are pure static functions on `SymbolGlyphControl`, assertable with
|
||||
/// no control on screen and no draw.
|
||||
@Test("A glyph's point size and drawn size follow the actual face rect, not the pure metrics")
|
||||
func glyphSizesOffTheActualFaceRect() {
|
||||
// A short, wide rect — the shape the card sidebar's row actually proposes now that the field
|
||||
// is wider than it is tall. The binding dimension is height, not width.
|
||||
let wideRect = NSRect(x: 0, y: 0, width: 200, height: 18)
|
||||
#expect(SymbolComboControl.glyphPointSize(forFace: wideRect) == 18)
|
||||
let wideRect = NSRect(x: 0, y: 0, width: 200, height: 27)
|
||||
#expect(SymbolGlyphControl.glyphPointSize(forFace: wideRect) == 27)
|
||||
|
||||
// A tall, narrow rect, for symmetry — the binding dimension flips to width.
|
||||
let tallRect = NSRect(x: 0, y: 0, width: 12, height: 40)
|
||||
#expect(SymbolComboControl.glyphPointSize(forFace: tallRect) == 12)
|
||||
#expect(SymbolGlyphControl.glyphPointSize(forFace: tallRect) == 12)
|
||||
|
||||
// A glyph configured within its rect never needs to grow.
|
||||
let snugSize = NSSize(width: 18, height: 18)
|
||||
#expect(SymbolComboControl.fittedSize(for: snugSize, in: wideRect) == snugSize)
|
||||
let snugSize = NSSize(width: 27, height: 27)
|
||||
#expect(SymbolGlyphControl.fittedSize(for: snugSize, in: wideRect) == snugSize)
|
||||
|
||||
// A horizontally elongated glyph (wider than the point size it was configured at) overshoots
|
||||
// the rect on its long axis and must be scaled down proportionally, not clipped.
|
||||
let elongated = NSSize(width: 36, height: 18)
|
||||
let fitted = SymbolComboControl.fittedSize(for: elongated, in: wideRect)
|
||||
#expect(fitted.width <= wideRect.width)
|
||||
#expect(fitted.height <= wideRect.height)
|
||||
// A glyph wider than the point size it was configured at genuinely overshoots a *narrow*
|
||||
// rect on its long axis (the realistic case: `tallRect`'s 12pt binding dimension, a symbol a
|
||||
// touch wider than tall at that size) and must be scaled down proportionally, not clipped.
|
||||
let elongated = NSSize(width: 16, height: 12)
|
||||
let fitted = SymbolGlyphControl.fittedSize(for: elongated, in: tallRect)
|
||||
#expect(fitted.width < elongated.width, "the wide axis must actually shrink")
|
||||
#expect(fitted.width <= tallRect.width)
|
||||
#expect(fitted.height <= tallRect.height)
|
||||
#expect(abs(fitted.width / fitted.height - elongated.width / elongated.height) < 0.001,
|
||||
"the scale-down must preserve the glyph's own aspect ratio")
|
||||
}
|
||||
|
||||
/// **The two controls are the same control.** Both are `ComboFieldControl`s and both take their
|
||||
/// **The two controls are the same control.** Both are `PickerRectControl`s and both take their
|
||||
/// geometry from the same value, so a change to one lands on the other — which is the whole of
|
||||
/// the parity claim, and cheaper to assert than any pair of measurements.
|
||||
/// the parity claim, and cheaper to assert than any pair of measurements. There are no zones left
|
||||
/// to compare (`PickerRect.swift`'s own retirement of the trigger strip); the whole bounds is the
|
||||
/// one hit zone on both, so intrinsic size is the whole of what "same chrome" means now.
|
||||
@MainActor
|
||||
@Test("Both combos are the same chrome, at the same size")
|
||||
func bothCombosShareTheChrome() {
|
||||
let colour = ColorComboControl(frame: .zero)
|
||||
let symbol = SymbolComboControl(frame: .zero)
|
||||
for control in [colour as ComboFieldControl, symbol] {
|
||||
@Test("Both rectangles are the same chrome, at the same size")
|
||||
func bothRectanglesShareTheChrome() {
|
||||
let colour = ColorSwatchControl(frame: .zero)
|
||||
let symbol = SymbolGlyphControl(frame: .zero)
|
||||
for control in [colour as PickerRectControl, symbol] {
|
||||
control.metrics = .metrics(bodyPointSize: 13)
|
||||
}
|
||||
#expect(colour.intrinsicContentSize.height == symbol.intrinsicContentSize.height)
|
||||
#expect(colour.intrinsicContentSize.width == symbol.intrinsicContentSize.width)
|
||||
colour.setFrameSize(NSSize(width: 120, height: colour.intrinsicContentSize.height))
|
||||
symbol.setFrameSize(NSSize(width: 120, height: symbol.intrinsicContentSize.height))
|
||||
#expect(colour.triggerRect == symbol.triggerRect, "the trigger zones must line up")
|
||||
#expect(colour.faceZone == symbol.faceZone, "the face zones must line up")
|
||||
}
|
||||
|
||||
/// The two zones tile the control exactly — no dead strip between them, no overlap that would
|
||||
/// make one door swallow the other's clicks.
|
||||
@MainActor
|
||||
@Test("The face and the trigger tile the control with no gap and no overlap")
|
||||
func zonesTileTheControl() {
|
||||
let control = SymbolComboControl(frame: NSRect(x: 0, y: 0, width: 140, height: 18))
|
||||
control.metrics = .metrics(bodyPointSize: 13)
|
||||
#expect(control.faceZone.maxX == control.triggerRect.minX)
|
||||
#expect(control.faceZone.minX == control.bounds.minX)
|
||||
#expect(control.triggerRect.maxX == control.bounds.maxX)
|
||||
#expect(control.faceZone.width + control.triggerRect.width == control.bounds.width)
|
||||
}
|
||||
|
||||
/// A control too narrow for its own trigger must not hand the face a negative width — a sidebar
|
||||
/// squeezed to nothing is a layout bug, not a crash.
|
||||
@MainActor
|
||||
@Test("A control narrower than its trigger degrades to an empty face")
|
||||
func degenerateWidthIsSafe() {
|
||||
let control = SymbolComboControl(frame: NSRect(x: 0, y: 0, width: 4, height: 18))
|
||||
control.metrics = .metrics(bodyPointSize: 13)
|
||||
#expect(control.faceZone.width >= 0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user