Owner's first review of the combo rework (2026-08-09): remove the face padding, make the field taller and narrower at about a 4:5 width:height ratio, and center the symbol glyph in its face. All three land in ComboFieldMetrics, so ColorComboControl and SymbolComboControl stay the identical shape they were built to share. - ComboFieldMetrics grows a width figure (height * widthToHeightRatio, 0.8), replacing NSView.noIntrinsicMetric — every combo now carries its own taller, narrower intrinsic size instead of stretching to whatever a caller's frame proposed. - facePaddingH/facePaddingV/glyphPadding are gone; a face fills its zone edge to edge. glyphPointSize is now whichever of the face's own width/height is smaller, with nothing subtracted for padding that no longer exists. - SymbolComboControl.drawFace centers the glyph on both axes — it only ever centered vertically before, despite its own doc comment claiming otherwise. - ComboFieldControl.drawTrigger bounds its chevron square by the smaller of the trigger strip's own width/height, not height alone, since the strip is no longer close to square once the field is much taller than it is wide. - CardSidebarSections drops the sidebar's old '* 0.55' fixed-width frame on both combo rows; each control now sizes itself, and both anchors (card sidebar, board popover) compose the narrower field with no other changes needed. - ComboFieldMetricsTests updated for the new figures, plus a ratio-holds-at-every-size test and a rewritten glyph-fit test matching the no-padding rule. Verification: xcodebuild build succeeded; xcodebuild test -only-testing:KanbanTests — 3220 tests in 559 suites, 3 failures, all PointerLatencyTests (documented locked-screen environmental mode, confirmed unrelated by isolated rerun). Pixel verification unexercised — same locked-screen constraint the first pass hit. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
451 lines
22 KiB
Swift
451 lines
22 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
/// A reusable colour-picker combo: a collapsed face split into **two zones**, Xcode's inspector
|
||
/// colour combo's own shape — a flat swatch of the current value filling almost the whole control,
|
||
/// and a narrow chevron trigger at the trailing edge. Clicking the swatch opens
|
||
/// `NSColorPanel.shared` directly; clicking the trigger pops a dropdown of **None**, the role's own
|
||
/// palette colours, an off-palette current value stated verbatim when there is one, and
|
||
/// **Other…**, which hands off to that same panel — the swatch and **Other…** are two doors onto
|
||
/// one panel takeover (`ColorComboView.Coordinator.openColorPanel()`).
|
||
///
|
||
/// It is the second surface `background`/`iconColor` can be set from, beside the well grid
|
||
/// (`StyleEditor.swift`'s `StyleEditorView`) — the well grid stays exactly as it is; this is a
|
||
/// narrower, single-value control for a context where a whole grid would not fit (`CardStyleSection`'s
|
||
/// own labeled row).
|
||
///
|
||
/// ### Two halves, the same split every other file here draws
|
||
///
|
||
/// `ColorComboRole`, `ColorComboItem`, `ColorComboMatch` and `ColorComboModel` are the **pure model**
|
||
/// — item lists, selection matching, hex normalization, display-name casing — every rule a test can
|
||
/// hold without an `NSView` in sight. `ColorComboView` is the thin AppKit bridge that draws it and
|
||
/// answers clicks, exactly the `StyleEditorLayout`/`StyleEditorView` split in `StyleEditor.swift`.
|
||
/// (Its collapsed face has its *own*, unrelated two-zone split — swatch versus trigger,
|
||
/// `ColorComboControl`'s own doc comment — which has nothing to do with this pure-model/view one.)
|
||
|
||
// MARK: - Role
|
||
|
||
/// Which of the two palettes a combo offers — `Palette.backgrounds` for `background`,
|
||
/// `Palette.foregrounds` for `iconColor`/icon tints. Both tables already answer either field
|
||
/// (`Palette.nsColor(for:)`), so a combo's *role* is only about which table it lists, never about
|
||
/// which values it can resolve.
|
||
enum ColorComboRole: Sendable, Equatable {
|
||
case background
|
||
case foreground
|
||
|
||
/// The rows this picker offers — one per entry in the role's own table (sixteen since
|
||
/// 2026-08-09; the count is the palette's business, not this type's).
|
||
var palette: [PaletteColor] {
|
||
switch self {
|
||
case .background: Palette.backgrounds
|
||
case .foreground: Palette.foregrounds
|
||
}
|
||
}
|
||
|
||
/// The *other* picker's table — consulted only to name a foreign palette value in the dynamic
|
||
/// current-value row (`ColorComboModel.match`). Never offered as a row of this picker's own,
|
||
/// which is what keeps "background lists backgrounds" true even though `Palette.nsColor(for:)`
|
||
/// itself would happily resolve a foreground name.
|
||
var otherPalette: [PaletteColor] {
|
||
switch self {
|
||
case .background: Palette.foregrounds
|
||
case .foreground: Palette.backgrounds
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Rows and matching
|
||
|
||
/// One row of a `ColorComboView`'s dropdown, in display order.
|
||
enum ColorComboItem: Equatable {
|
||
/// Clears the field — the well grid's own leading None well, same removal.
|
||
case none
|
||
case separator
|
||
/// One of `role`'s own entries, by name. `ColorComboModel.displayName(_:)` is its title; the row
|
||
/// is never built with anything the role's own palette doesn't list.
|
||
case palette(String)
|
||
/// The live value's own row — present only when the stored value matches neither `.none` nor a
|
||
/// `.palette` row (`ColorComboModel.match` decides). `swatchValue` is the raw stored string a
|
||
/// swatch draws from (`Palette.nsColor(for:)`, lenient exactly like `PaletteSwatch`); `title` is
|
||
/// the display text `ColorComboModel.match` already worked out.
|
||
case current(swatchValue: String, title: String)
|
||
/// Opens `NSColorPanel.shared`.
|
||
case other
|
||
}
|
||
|
||
/// Which row a stored value checks — computed once and shared by the item list (`ColorComboModel.
|
||
/// menu`) and by anything that just wants to know "what does this resolve to" without building
|
||
/// rows, which is most of what a test wants to assert.
|
||
enum ColorComboMatch: Equatable {
|
||
case none
|
||
case palette(String)
|
||
case current(swatchValue: String, title: String)
|
||
}
|
||
|
||
/// The full dropdown for one role at one value: its rows, and the index of the checked one.
|
||
struct ColorComboMenu: Equatable {
|
||
let items: [ColorComboItem]
|
||
/// Always a valid index into `items` — the None row exists in every menu, so there is always at
|
||
/// least one candidate to fall back to.
|
||
let selectedIndex: Int
|
||
}
|
||
|
||
// MARK: - The pure model
|
||
|
||
/// The whole of what a `ColorComboView` shows, as pure functions of `role` and a stored value —
|
||
/// no `NSView`, no store, nothing a `ColorComboTests` case can't hold still.
|
||
enum ColorComboModel {
|
||
|
||
// MARK: Display
|
||
|
||
/// Kebab-case palette name → Title Case with hyphens as spaces: `"light-cayenne"` →
|
||
/// `"Light Cayenne"`, `"smokey-rich-eggplant"` → `"Smokey Rich Eggplant"` — the one place a
|
||
/// palette name becomes a row's title rather than its stored spelling.
|
||
static func displayName(_ name: String) -> String {
|
||
name.split(separator: "-")
|
||
.map { $0.isEmpty ? "" : $0.prefix(1).uppercased() + $0.dropFirst() }
|
||
.joined(separator: " ")
|
||
}
|
||
|
||
// MARK: Matching
|
||
|
||
/// Which row `value` checks, given `role`:
|
||
/// - `nil` → `.none`.
|
||
/// - a name in `role`'s own palette → `.palette(name)`, matched exactly — `Palette`'s own
|
||
/// case-sensitive rule, unchanged here.
|
||
/// - a hex that, normalized, equals one of `role`'s palette hexes → that colour's `.palette`
|
||
/// match, **by name** — a panel pick landing exactly on a palette colour selects the name, so
|
||
/// picking it again from the panel later re-emits the name rather than drifting to a hex.
|
||
/// - anything else (a foreign palette name, a custom hex, or unresolvable garbage) → `.current`,
|
||
/// titled with the other picker's display name when `value` is one of *its* own, else
|
||
/// `value` itself, uppercased when it looks like hex and left verbatim otherwise.
|
||
static func match(role: ColorComboRole, value: String?) -> ColorComboMatch {
|
||
guard let value else { return .none }
|
||
if role.palette.contains(where: { $0.name == value }) {
|
||
return .palette(value)
|
||
}
|
||
if let normalized = normalizedHex(value),
|
||
let hit = role.palette.first(where: { normalizedHex($0.hex) == normalized }) {
|
||
return .palette(hit.name)
|
||
}
|
||
return .current(swatchValue: value, title: currentTitle(role: role, value: value))
|
||
}
|
||
|
||
/// The dynamic current-value row's title — the other table's display name when `value` is one
|
||
/// of its own, the raw string otherwise (hex shown uppercase, matching `NSColor.
|
||
/// paletteHexString`'s own casing so a stored value and a freshly panel-picked one read alike).
|
||
private static func currentTitle(role: ColorComboRole, value: String) -> String {
|
||
if let foreign = role.otherPalette.first(where: { $0.name == value }) {
|
||
return displayName(foreign.name)
|
||
}
|
||
return value.hasPrefix("#") ? value.uppercased() : value
|
||
}
|
||
|
||
// MARK: Item list
|
||
|
||
/// The dropdown's full row list and which row is checked, for `role` at `value`: **None**,
|
||
/// separator, the role's own entries, then — only when `match` lands on `.current` — that dynamic row,
|
||
/// separator, **Other…**.
|
||
static func menu(role: ColorComboRole, value: String?) -> ColorComboMenu {
|
||
var items: [ColorComboItem] = [.none, .separator]
|
||
items.append(contentsOf: role.palette.map { .palette($0.name) })
|
||
|
||
let selectedIndex: Int
|
||
switch match(role: role, value: value) {
|
||
case .none:
|
||
selectedIndex = 0
|
||
case let .palette(name):
|
||
selectedIndex = items.firstIndex(of: .palette(name)) ?? 0
|
||
case let .current(swatchValue, title):
|
||
items.append(.current(swatchValue: swatchValue, title: title))
|
||
selectedIndex = items.count - 1
|
||
}
|
||
|
||
items.append(.separator)
|
||
items.append(.other)
|
||
return ColorComboMenu(items: items, selectedIndex: selectedIndex)
|
||
}
|
||
|
||
// MARK: Hex normalization
|
||
|
||
/// `#RRGGBB`/`#RRGGBBAA` → uppercase, alpha-`FF` collapsed to six digits — the string-side half
|
||
/// of the round trip `NSColor.paletteHexString` builds (Palette.swift), used here purely for
|
||
/// **comparison**: two spellings of the same opaque colour normalize to the same string, so a
|
||
/// stored `#b6071eff` matches a palette entry's `#B6071E` exactly as a bare `#b6071e` would.
|
||
/// `nil` for anything that isn't `#` followed by six or eight hex digits, so a malformed value
|
||
/// never accidentally matches a palette colour by coincidence.
|
||
static func normalizedHex(_ value: String) -> String? {
|
||
var upper = value.uppercased()
|
||
guard upper.hasPrefix("#") else { return nil }
|
||
let digits = upper.dropFirst()
|
||
guard digits.count == 6 || digits.count == 8, digits.allSatisfy(\.isHexDigit) else { return nil }
|
||
if digits.count == 8, digits.hasSuffix("FF") {
|
||
upper.removeLast(2)
|
||
}
|
||
return upper
|
||
}
|
||
}
|
||
|
||
// MARK: - View
|
||
|
||
/// The AppKit bridge: a two-zone collapsed face (`ColorComboControl`) whose dropdown is
|
||
/// `ColorComboModel.menu(role:value:)` — built exactly as it always was, just handed to the control
|
||
/// to pop instead of being assigned as an `NSPopUpButton`'s own `menu`. Two click targets sharing one
|
||
/// menu-plus-panel contract is the one thing `NSPopUpButton` cannot do on its own: it has exactly one
|
||
/// hit zone for exactly one action.
|
||
struct ColorComboView: NSViewRepresentable {
|
||
|
||
let role: ColorComboRole
|
||
/// The raw stored value — a palette name or a hand-written hex, exactly as the frontmatter field
|
||
/// carries it. Never a resolved `Color`: matching needs the string, not what it renders as.
|
||
let value: String?
|
||
let isEnabled: Bool
|
||
/// One discrete row picked — **None**, one of the palette rows, or the dynamic current-value row.
|
||
/// Fired once, synchronously; the call site commits it immediately.
|
||
var onChange: @MainActor (String?) -> Void
|
||
/// One tick of a live `NSColorPanel` drag opened from **Other…** or the swatch zone — fires
|
||
/// repeatedly while the user is still adjusting the colour. Kept separate from `onChange`
|
||
/// because the two halves of this control's contract differ at the call site
|
||
/// (`CardStyleSection`): a discrete pick commits immediately, a panel tick is the caller's to
|
||
/// debounce and never feeds style recents.
|
||
var onPanelChange: @MainActor (String?) -> Void
|
||
|
||
func makeNSView(context: Context) -> ColorComboControl {
|
||
let control = ColorComboControl(frame: .zero)
|
||
// The two zones' jobs, wired once (`ComboFieldControl`'s grammar): the face opens the same
|
||
// panel takeover **Other…** does, the trigger pops the dropdown. Closures, not target/action
|
||
// pairs — there is exactly one caller each and no `NSMenuItem`-style Objective-C boundary to
|
||
// cross for them.
|
||
control.onFaceClick = { [weak coordinator = context.coordinator] in
|
||
coordinator?.openColorPanel()
|
||
}
|
||
control.onTriggerClick = { [weak control] in
|
||
control?.popUpMenu()
|
||
}
|
||
return control
|
||
}
|
||
|
||
func updateNSView(_ control: ColorComboControl, context: Context) {
|
||
context.coordinator.role = role
|
||
context.coordinator.onChange = onChange
|
||
context.coordinator.onPanelChange = onPanelChange
|
||
control.metrics = .current
|
||
control.isEnabled = isEnabled
|
||
context.coordinator.rebuild(control, value: value)
|
||
}
|
||
|
||
/// Obeys an explicit finite proposal when SwiftUI hands one over — never the widest menu item,
|
||
/// which is what the old caller-supplied `width` input existed to work around. Falls back to the
|
||
/// control's own intrinsic width (`ComboFieldControl.intrinsicContentSize`, the 2026-08-09
|
||
/// iteration's taller-narrower field) on an unconstrained measuring pass, which is the normal case
|
||
/// now that the sidebar row no longer forces a wider frame of its own (`CardStyleSection`).
|
||
func sizeThatFits(_ proposal: ProposedViewSize, nsView: ColorComboControl, context: Context) -> CGSize? {
|
||
let width: CGFloat
|
||
if let proposed = proposal.width, proposed.isFinite {
|
||
width = proposed
|
||
} else {
|
||
width = nsView.intrinsicContentSize.width
|
||
}
|
||
return CGSize(width: width, height: nsView.intrinsicContentSize.height)
|
||
}
|
||
|
||
/// Detaches the colour panel's target/action if this coordinator still holds them — "last-writer
|
||
/// wins" for any control that took the panel over afterward (this view's own doc comment).
|
||
static func dismantleNSView(_ control: ColorComboControl, coordinator: Coordinator) {
|
||
coordinator.detachColorPanel()
|
||
}
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(role: role, onChange: onChange, onPanelChange: onPanelChange)
|
||
}
|
||
|
||
// MARK: Coordinator
|
||
|
||
/// The one object every menu action and the colour panel's action target — a class because
|
||
/// `NSColorPanel.setTarget(_:)` needs something with reference identity to detach from later,
|
||
/// and `@MainActor` because every AppKit call it makes has to be.
|
||
@MainActor
|
||
final class Coordinator: NSObject {
|
||
fileprivate var role: ColorComboRole
|
||
fileprivate var currentValue: String?
|
||
fileprivate var onChange: @MainActor (String?) -> Void
|
||
fileprivate var onPanelChange: @MainActor (String?) -> Void
|
||
|
||
/// ~44×14pt — a menu row's swatch, wide enough beside its title to read as a colour sample
|
||
/// rather than a bullet.
|
||
private static let menuSwatchSize = NSSize(width: 44, height: 14)
|
||
|
||
/// This coordinator's handle on `NSColorPanel.shared` — the takeover, its continuous action
|
||
/// and its last-writer-wins detach all live in `SystemColorPanel` now, shared with the two
|
||
/// surfaces that gained an **Other…** on 2026-08-09.
|
||
private let colorPanel = SystemColorPanel()
|
||
|
||
init(
|
||
role: ColorComboRole,
|
||
onChange: @escaping @MainActor (String?) -> Void,
|
||
onPanelChange: @escaping @MainActor (String?) -> Void
|
||
) {
|
||
self.role = role
|
||
self.onChange = onChange
|
||
self.onPanelChange = onPanelChange
|
||
}
|
||
|
||
/// Rebuilds the dropdown for `value` and hands the control the menu, its checked item (the
|
||
/// popup anchor `ColorComboControl.popUpMenu()` positions against, and the source of its
|
||
/// accessibility value), and the value its swatch zone should draw. Cheap enough — a dozen
|
||
/// rows, a fistful of small menu-row images — to redo wholesale on every SwiftUI update
|
||
/// rather than diffing against what was there before.
|
||
func rebuild(_ control: ColorComboControl, value: String?) {
|
||
currentValue = value
|
||
let menu = NSMenu()
|
||
let built = ColorComboModel.menu(role: role, value: value)
|
||
var checkedItem: NSMenuItem?
|
||
for (index, item) in built.items.enumerated() {
|
||
if item == .separator {
|
||
menu.addItem(.separator())
|
||
continue
|
||
}
|
||
let menuItem = self.menuItem(for: item)
|
||
let isChecked = index == built.selectedIndex
|
||
menuItem.state = isChecked ? .on : .off
|
||
menu.addItem(menuItem)
|
||
if isChecked { checkedItem = menuItem }
|
||
}
|
||
control.comboMenu = menu
|
||
control.checkedItem = checkedItem
|
||
control.accessibilityValueText = checkedItem?.title
|
||
control.swatchValue = value
|
||
}
|
||
|
||
/// See `ColorComboView.dismantleNSView(_:coordinator:)`.
|
||
func detachColorPanel() {
|
||
colorPanel.detach()
|
||
}
|
||
|
||
private func menuItem(for item: ColorComboItem) -> NSMenuItem {
|
||
switch item {
|
||
case .none:
|
||
let menuItem = NSMenuItem(title: "None", action: #selector(selectNone), keyEquivalent: "")
|
||
menuItem.target = self
|
||
menuItem.image = PaletteSwatch.rectImage(for: nil, size: Self.menuSwatchSize)
|
||
return menuItem
|
||
|
||
case let .palette(name):
|
||
let menuItem = NSMenuItem(
|
||
title: ColorComboModel.displayName(name),
|
||
action: #selector(selectValue(_:)),
|
||
keyEquivalent: ""
|
||
)
|
||
menuItem.target = self
|
||
menuItem.representedObject = name
|
||
menuItem.image = PaletteSwatch.rectImage(for: name, size: Self.menuSwatchSize)
|
||
return menuItem
|
||
|
||
case let .current(swatchValue, title):
|
||
let menuItem = NSMenuItem(title: title, action: #selector(selectValue(_:)), keyEquivalent: "")
|
||
menuItem.target = self
|
||
menuItem.representedObject = swatchValue
|
||
menuItem.image = PaletteSwatch.rectImage(for: swatchValue, size: Self.menuSwatchSize)
|
||
return menuItem
|
||
|
||
case .other:
|
||
let menuItem = NSMenuItem(title: "Other…", action: #selector(openColorPanel), keyEquivalent: "")
|
||
menuItem.target = self
|
||
return menuItem
|
||
|
||
case .separator:
|
||
// Unreached: `rebuild` handles `.separator` before calling this. Kept so the switch
|
||
// stays total against a case list a future row could still grow.
|
||
return NSMenuItem.separator()
|
||
}
|
||
}
|
||
|
||
@objc private func selectNone() {
|
||
onChange(nil)
|
||
}
|
||
|
||
@objc private func selectValue(_ sender: NSMenuItem) {
|
||
onChange(sender.representedObject as? String)
|
||
}
|
||
|
||
/// Opens the shared Colors panel seeded with the current resolved colour, streaming what it
|
||
/// picks into `onPanelChange` — the palette name when the colour lands exactly on one of
|
||
/// `role`'s own entries, the hex otherwise. Every rule of that takeover is
|
||
/// `SystemColorPanel`'s; this is the seed and the destination.
|
||
///
|
||
/// Two callers, one takeover: the dropdown's own **Other…** row (`#selector` target above)
|
||
/// and `ColorComboControl`'s face-zone click (wired in `ColorComboView.makeNSView`) —
|
||
/// Xcode's own two-zone combo opens the same panel from either half, and this is the one
|
||
/// place that happens.
|
||
@objc func openColorPanel() {
|
||
colorPanel.present(
|
||
seed: currentValue.flatMap(Palette.nsColor(for:)),
|
||
matching: role.palette
|
||
) { [weak self] value in
|
||
self?.onPanelChange(value)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - The control
|
||
|
||
/// The collapsed face: `ComboFieldControl` with a **swatch** in its face zone.
|
||
///
|
||
/// Everything that makes it a two-zone combo — the field, the hairline, the trailing chevron square,
|
||
/// the hit split, the disabled compositing, the geometry — is the base class's now (ComboField.swift),
|
||
/// shared with the symbol combo so the two cannot drift apart. What is left here is the one thing that
|
||
/// is actually about *colour*: the swatch, and the menu the trigger pops.
|
||
///
|
||
/// Plain internal, not `private`/`fileprivate`, even though nothing outside this file constructs one
|
||
/// directly: it is `ColorComboView`'s `NSViewType`, an associated-type witness the compiler requires
|
||
/// to be at least as visible as `ColorComboView` itself (internal, usable module-wide) — same
|
||
/// reasoning as the un-modified-access `Coordinator` a few lines up.
|
||
final class ColorComboControl: ComboFieldControl {
|
||
|
||
/// The value the swatch zone currently draws — a palette name or a hand-written hex, exactly as
|
||
/// `ColorComboView.Coordinator.rebuild(_:value:)` hands it over on every SwiftUI update.
|
||
var swatchValue: String? {
|
||
didSet {
|
||
guard swatchValue != oldValue else { return }
|
||
needsDisplay = true
|
||
}
|
||
}
|
||
|
||
/// The dropdown the trigger zone pops, and the row within it that should read as checked — both
|
||
/// `Coordinator.rebuild(_:value:)`'s to hand over on every rebuild, always together (`checkedItem`
|
||
/// is always one of `comboMenu`'s own items). This control never builds a row itself; it only
|
||
/// positions and pops what it is given.
|
||
var comboMenu: NSMenu?
|
||
var checkedItem: NSMenuItem?
|
||
|
||
/// The colour rect, drawn exactly like `PaletteSwatch.rectImage`: a `textBackgroundColor`
|
||
/// underlay so a translucent stored colour composites the same way in light and dark, the
|
||
/// resolved colour on top, a `separatorColor` hairline stroke last. `nil`/unresolvable value →
|
||
/// underlay + stroke only, the same "there is no colour, so show none" rule.
|
||
///
|
||
/// **No inset, since 2026-08-09** — the swatch fills the whole face zone rather than sitting in a
|
||
/// padded ring inside it (the owner's "remove padding from the combo").
|
||
override func drawFace(in rect: NSRect) {
|
||
guard rect.width > 0, rect.height > 0 else { return }
|
||
let path = NSBezierPath(roundedRect: rect, xRadius: metrics.cornerRadius, yRadius: metrics.cornerRadius)
|
||
NSColor.textBackgroundColor.setFill()
|
||
path.fill()
|
||
if let swatchValue, let color = Palette.nsColor(for: swatchValue) {
|
||
color.setFill()
|
||
path.fill()
|
||
}
|
||
NSColor.separatorColor.setStroke()
|
||
path.lineWidth = 1
|
||
path.stroke()
|
||
}
|
||
|
||
/// Standard popup placement: `comboMenu` is asked to land `checkedItem` at the control's own top
|
||
/// edge, the same non-pulldown anchor `NSPopUpButton` itself uses so the checked row appears
|
||
/// where the control's own face is rather than wherever the pointer happened to be.
|
||
func popUpMenu() {
|
||
guard let comboMenu else { return }
|
||
comboMenu.popUp(positioning: checkedItem, at: NSPoint(x: 0, y: bounds.height), in: self)
|
||
}
|
||
}
|