Files
steipete--codexbar/Sources/CodexBar/StatusItemController+SwitcherViews.swift
T
2026-07-13 12:22:33 +08:00

1670 lines
64 KiB
Swift

import AppKit
import CodexBarCore
import QuartzCore
enum ProviderSwitcherSelection: Hashable {
case overview
case provider(UsageProvider)
}
final class ProviderSwitcherView: NSView {
private struct Segment {
let selection: ProviderSwitcherSelection
let image: NSImage
let title: String
}
fileprivate struct QuotaIndicator {
let track: NSView
let fill: NSView
var fillWidthConstraint: NSLayoutConstraint
var fillRatio: CGFloat
}
private let segments: [Segment]
private let onSelect: (ProviderSwitcherSelection) -> Void
private let showsIcons: Bool
private let weeklyRemainingProvider: (UsageProvider) -> Double?
private var buttons: [NSButton] = []
private var quotaIndicators: [ObjectIdentifier: QuotaIndicator] = [:]
private var hoverTrackingArea: NSTrackingArea?
private var segmentWidths: [CGFloat] = []
private let selectedBackground = NSColor.controlAccentColor.cgColor
private let unselectedBackground = NSColor.clear.cgColor
private let selectedTextColor = NSColor.white
private let unselectedTextColor = NSColor.secondaryLabelColor
private let stackedIcons: Bool
private let rowCount: Int
private let rowSpacing: CGFloat
private let rowHeight: CGFloat
private var preferredWidth: CGFloat = 0
private var hoveredButtonTag: Int?
private var pressedButtonTag: Int?
private var selectedSegmentIndex: Int?
private static let quotaIndicatorHeight: CGFloat = 2
private static let quotaIndicatorBottomInset: CGFloat = 2
private static let quotaIndicatorHorizontalInset: CGFloat = 8
init(
providers: [UsageProvider],
selected: ProviderSwitcherSelection?,
includesOverview: Bool,
width: CGFloat,
showsIcons: Bool,
iconProvider: (UsageProvider) -> NSImage,
weeklyRemainingProvider: @escaping (UsageProvider) -> Double?,
onSelect: @escaping (ProviderSwitcherSelection) -> Void)
{
let minimumGap: CGFloat = 1
var segments = providers.map { provider in
let fullTitle = Self.switcherTitle(for: provider)
let icon = iconProvider(provider)
icon.isTemplate = true
// Avoid any resampling: we ship exact 16pt/32px assets for crisp rendering.
icon.size = NSSize(width: 16, height: 16)
return Segment(
selection: .provider(provider),
image: icon,
title: fullTitle)
}
if includesOverview {
let overviewIcon = Self.overviewIcon()
overviewIcon.isTemplate = true
overviewIcon.size = NSSize(width: 16, height: 16)
segments.insert(
Segment(
selection: .overview,
image: overviewIcon,
title: L("Overview")),
at: 0)
}
self.segments = segments
self.onSelect = onSelect
self.showsIcons = showsIcons
self.weeklyRemainingProvider = weeklyRemainingProvider
self.stackedIcons = showsIcons && self.segments.count > 3
let initialOuterPadding = Self.switcherOuterPadding(
for: width,
count: self.segments.count,
minimumGap: minimumGap)
let initialMaxAllowedSegmentWidth = Self.maxAllowedUniformSegmentWidth(
for: width,
count: self.segments.count,
outerPadding: initialOuterPadding,
minimumGap: minimumGap)
self.rowCount = Self.switcherRowCount(
width: width,
count: self.segments.count,
maxAllowedSegmentWidth: initialMaxAllowedSegmentWidth,
stackedIcons: self.stackedIcons)
self.rowSpacing = self.stackedIcons ? 4 : 2
self.rowHeight = Self.switcherButtonHeight(stackedIcons: self.stackedIcons, rowCount: self.rowCount)
let height: CGFloat = self.rowHeight * CGFloat(self.rowCount)
+ self.rowSpacing * CGFloat(max(0, self.rowCount - 1))
self.preferredWidth = width
super.init(frame: NSRect(x: 0, y: 0, width: width, height: height))
Self.clearButtonWidthCache()
self.wantsLayer = true
self.layer?.masksToBounds = false
func makeButton(index: Int, segment: Segment) -> NSButton {
let button: NSButton
if self.stackedIcons {
let stacked = StackedToggleButton(
title: segment.title,
image: segment.image,
target: self,
action: #selector(self.handleSelection(_:)))
stacked.setAllowsTwoLineTitle(self.rowCount >= 3)
if self.rowCount >= 4 {
stacked.setTitleFontSize(NSFont.smallSystemFontSize - 3)
}
button = stacked
} else if self.showsIcons {
let inline = InlineIconToggleButton(
title: segment.title,
image: segment.image,
target: self,
action: #selector(self.handleSelection(_:)))
button = inline
} else {
button = PaddedToggleButton(
title: segment.title,
target: self,
action: #selector(self.handleSelection(_:)))
}
button.tag = index
if self.showsIcons {
if self.stackedIcons {
// StackedToggleButton manages its own image view.
} else {
// InlineIconToggleButton manages its own image view.
}
} else {
button.image = nil
button.imagePosition = .noImage
}
button.bezelStyle = .regularSquare
button.isBordered = false
button.controlSize = .small
button.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
button.setButtonType(.toggle)
button.contentTintColor = self.unselectedTextColor
button.alignment = .center
button.wantsLayer = true
button.layer?.cornerRadius = 6
button.state = (selected == segment.selection) ? .on : .off
button.toolTip = nil
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: self.rowHeight).isActive = true
self.buttons.append(button)
return button
}
for (index, segment) in self.segments.enumerated() {
let button = makeButton(index: index, segment: segment)
self.addSubview(button)
self.addQuotaIndicator(
to: button,
selection: segment.selection,
remainingPercent: self.remainingPercent(for: segment.selection))
}
self.selectedSegmentIndex = selected.flatMap { selected in
self.segments.firstIndex { $0.selection == selected }
}
let layoutCount = Self.layoutCount(for: self.segments.count, rows: self.rowCount)
let requiredUniformWidth = self.stackedIcons
? nil
: self.buttons.map(Self.maxToggleWidth(for:)).max()
let layoutMetrics = Self.switcherLayoutMetrics(
for: width,
count: layoutCount,
minimumGap: minimumGap,
requiredSegmentWidth: requiredUniformWidth)
let uniformWidth: CGFloat
if self.rowCount > 1 || !self.stackedIcons {
uniformWidth = self.applyUniformSegmentWidth(maxAllowedWidth: layoutMetrics.maxAllowedSegmentWidth)
if uniformWidth > 0 {
self.segmentWidths = Array(repeating: uniformWidth, count: self.buttons.count)
}
} else {
self.segmentWidths = self.applyNonUniformSegmentWidths(
totalWidth: width,
outerPadding: layoutMetrics.outerPadding,
minimumGap: minimumGap)
uniformWidth = 0
}
self.applyLayout(
outerPadding: layoutMetrics.outerPadding,
minimumGap: minimumGap,
uniformWidth: uniformWidth)
if width > 0 {
self.preferredWidth = width
self.frame.size.width = width
}
self.updateButtonStyles()
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
self.updateButtonStyles()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let window = self.window {
window.acceptsMouseMovedEvents = true
} else if self.hoveredButtonTag != nil {
self.hoveredButtonTag = nil
self.updateButtonStyles()
}
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let hoverTrackingArea {
self.removeTrackingArea(hoverTrackingArea)
}
let trackingArea = NSTrackingArea(
rect: .zero,
options: [
.activeAlways,
.inVisibleRect,
.mouseEnteredAndExited,
.mouseMoved,
],
owner: self,
userInfo: nil)
self.addTrackingArea(trackingArea)
self.hoverTrackingArea = trackingArea
}
override func mouseMoved(with event: NSEvent) {
let location = self.convert(event.locationInWindow, from: nil)
let hoveredTag = self.button(at: location)?.tag
guard hoveredTag != self.hoveredButtonTag else { return }
self.hoveredButtonTag = hoveredTag
self.updateButtonStyles()
}
override func mouseExited(with event: NSEvent) {
guard self.hoveredButtonTag != nil else { return }
self.hoveredButtonTag = nil
self.updateButtonStyles()
}
// MARK: - Click handling
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
// NSMenu's tracking run loop occasionally drops NSButton target-action dispatch when the
// menu is rebuilt under the cursor (e.g. after switching back from a provider tab to
// Overview). The overrides in this section hit-test the parent view, then drive
// selection from mouseDown/mouseUp here so the click never has to round-trip through
// NSButton's tracking loop. See issue #867.
true
}
override func hitTest(_ point: NSPoint) -> NSView? {
let descendant = super.hitTest(point)
if descendant != nil, descendant !== self {
// Swallow any hit on a child NSButton so its tracking loop never sees the click.
return self
}
return descendant
}
override func mouseDown(with event: NSEvent) {
_ = self.handleMenuTrackingMouseDown(event)
}
override func mouseUp(with event: NSEvent) {
_ = self.handleMenuTrackingMouseUp(event)
}
@discardableResult
func handleMenuTrackingMouseDown(_ event: NSEvent) -> Bool {
guard event.type == .leftMouseDown else { return false }
let location = self.locationInView(for: event)
guard let pressedTag = self.button(at: location)?.tag,
self.segments.indices.contains(pressedTag)
else {
return false
}
self.pressedButtonTag = pressedTag
return true
}
@discardableResult
func handleMenuTrackingMouseUp(_ event: NSEvent) -> Bool {
guard event.type == .leftMouseUp else { return false }
defer { self.pressedButtonTag = nil }
guard let pressedTag = self.pressedButtonTag else { return false }
let location = self.locationInView(for: event)
guard let releasedTag = self.button(at: location)?.tag,
releasedTag == pressedTag
else {
return true
}
// Commit only after the matching release. The controller schedules structural menu
// replacement after this callback returns so AppKit can finish the tracking transaction.
self.applySelection(at: pressedTag)
return true
}
private func locationInView(for event: NSEvent) -> NSPoint {
guard let eventWindow = event.window,
let viewWindow = self.window,
eventWindow !== viewWindow
else {
return self.convert(event.locationInWindow, from: nil)
}
let screenLocation = eventWindow.convertPoint(toScreen: event.locationInWindow)
return self.convert(viewWindow.convertPoint(fromScreen: screenLocation), from: nil)
}
func handleKeyboardSelection(at index: Int) -> Bool {
guard self.segments.indices.contains(index) else { return false }
self.applySelection(at: index)
return true
}
private func applySelection(at index: Int) {
let selection = self.segments[index].selection
guard self.selectedSegmentIndex != index else {
self.updateSelection(selection)
return
}
self.updateSelection(selection)
self.onSelect(selection)
}
private func applyLayout(
outerPadding: CGFloat,
minimumGap: CGFloat,
uniformWidth: CGFloat)
{
if self.rowCount > 1 {
self.applyMultiRowLayout(
rowCount: self.rowCount,
outerPadding: outerPadding,
minimumGap: minimumGap,
uniformWidth: uniformWidth)
return
}
if self.buttons.count == 2 {
let left = self.buttons[0]
let right = self.buttons[1]
let gap = right.leadingAnchor.constraint(greaterThanOrEqualTo: left.trailingAnchor, constant: minimumGap)
gap.priority = .defaultHigh
NSLayoutConstraint.activate([
left.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding),
left.centerYAnchor.constraint(equalTo: self.centerYAnchor),
right.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding),
right.centerYAnchor.constraint(equalTo: self.centerYAnchor),
gap,
])
return
}
if self.buttons.count == 3 {
let left = self.buttons[0]
let mid = self.buttons[1]
let right = self.buttons[2]
let leftGap = mid.leadingAnchor.constraint(greaterThanOrEqualTo: left.trailingAnchor, constant: minimumGap)
leftGap.priority = .defaultHigh
let rightGap = right.leadingAnchor.constraint(
greaterThanOrEqualTo: mid.trailingAnchor,
constant: minimumGap)
rightGap.priority = .defaultHigh
NSLayoutConstraint.activate([
left.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding),
left.centerYAnchor.constraint(equalTo: self.centerYAnchor),
mid.centerXAnchor.constraint(equalTo: self.centerXAnchor),
mid.centerYAnchor.constraint(equalTo: self.centerYAnchor),
right.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding),
right.centerYAnchor.constraint(equalTo: self.centerYAnchor),
leftGap,
rightGap,
])
return
}
if self.buttons.count >= 4 {
let widths = self.segmentWidths.isEmpty
? self.buttons.map { ceil($0.fittingSize.width) }
: self.segmentWidths
let layoutWidth = self.preferredWidth > 0 ? self.preferredWidth : self.bounds.width
let availableWidth = max(0, layoutWidth - outerPadding * 2)
let gaps = max(1, widths.count - 1)
let computedGap = gaps > 0
? max(minimumGap, (availableWidth - widths.reduce(0, +)) / CGFloat(gaps))
: 0
let rowContainer = NSView()
rowContainer.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(rowContainer)
NSLayoutConstraint.activate([
rowContainer.topAnchor.constraint(equalTo: self.topAnchor),
rowContainer.bottomAnchor.constraint(equalTo: self.bottomAnchor),
rowContainer.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding),
rowContainer.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding),
])
var xOffset: CGFloat = 0
for (index, button) in self.buttons.enumerated() {
let width = index < widths.count ? widths[index] : 0
if self.stackedIcons {
NSLayoutConstraint.activate([
button.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor, constant: xOffset),
button.topAnchor.constraint(equalTo: rowContainer.topAnchor),
])
} else {
NSLayoutConstraint.activate([
button.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor, constant: xOffset),
button.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor),
])
}
xOffset += width + computedGap
}
return
}
if let first = self.buttons.first {
NSLayoutConstraint.activate([
first.centerXAnchor.constraint(equalTo: self.centerXAnchor),
first.centerYAnchor.constraint(equalTo: self.centerYAnchor),
])
}
}
private func applyMultiRowLayout(
rowCount: Int,
outerPadding: CGFloat,
minimumGap: CGFloat,
uniformWidth: CGFloat)
{
let rows = Self.splitRows(for: self.buttons, rowCount: rowCount)
let columns = rows.map(\.count).max() ?? 0
let layoutWidth = self.preferredWidth > 0 ? self.preferredWidth : self.bounds.width
let availableWidth = max(0, layoutWidth - outerPadding * 2)
let gaps = max(1, columns - 1)
let totalWidth = uniformWidth * CGFloat(columns)
let computedGap = gaps > 0
? max(minimumGap, (availableWidth - totalWidth) / CGFloat(gaps))
: 0
let gridContainer = NSView()
gridContainer.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(gridContainer)
NSLayoutConstraint.activate([
gridContainer.topAnchor.constraint(equalTo: self.topAnchor),
gridContainer.bottomAnchor.constraint(equalTo: self.bottomAnchor),
gridContainer.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding),
gridContainer.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding),
])
var rowViews: [NSView] = []
for _ in 0..<rowCount {
let row = NSView()
row.translatesAutoresizingMaskIntoConstraints = false
gridContainer.addSubview(row)
rowViews.append(row)
}
var rowConstraints: [NSLayoutConstraint] = []
for (index, row) in rowViews.enumerated() {
rowConstraints.append(row.leadingAnchor.constraint(equalTo: gridContainer.leadingAnchor))
rowConstraints.append(row.trailingAnchor.constraint(equalTo: gridContainer.trailingAnchor))
rowConstraints.append(row.heightAnchor.constraint(equalToConstant: self.rowHeight))
if index == 0 {
rowConstraints.append(row.topAnchor.constraint(equalTo: gridContainer.topAnchor))
} else {
rowConstraints.append(row.topAnchor.constraint(
equalTo: rowViews[index - 1].bottomAnchor,
constant: self.rowSpacing))
}
if index == rowViews.count - 1 {
rowConstraints.append(row.bottomAnchor.constraint(equalTo: gridContainer.bottomAnchor))
}
}
NSLayoutConstraint.activate(rowConstraints)
for (rowIndex, rowButtons) in rows.enumerated() {
guard rowIndex < rowViews.count else { continue }
let rowView = rowViews[rowIndex]
for (columnIndex, button) in rowButtons.enumerated() {
let xOffset = CGFloat(columnIndex) * (uniformWidth + computedGap)
NSLayoutConstraint.activate([
button.leadingAnchor.constraint(equalTo: gridContainer.leadingAnchor, constant: xOffset),
button.centerYAnchor.constraint(equalTo: rowView.centerYAnchor),
])
}
}
}
private static func switcherRowCount(
width: CGFloat,
count: Int,
maxAllowedSegmentWidth: CGFloat,
stackedIcons: Bool) -> Int
{
guard count > 1 else { return 1 }
let maxRows = min(4, count)
let fourRowThreshold = 15
let minimumComfortableAverage: CGFloat = stackedIcons ? 50 : 54
if count >= fourRowThreshold { return maxRows }
if maxAllowedSegmentWidth >= minimumComfortableAverage { return 1 }
for rows in 2...maxRows {
let perRow = self.layoutCount(for: count, rows: rows)
let outerPadding = self.switcherOuterPadding(for: width, count: perRow, minimumGap: 1)
let allowedWidth = self.maxAllowedUniformSegmentWidth(
for: width,
count: perRow,
outerPadding: outerPadding,
minimumGap: 1)
if allowedWidth >= minimumComfortableAverage { return rows }
}
return maxRows
}
private static func layoutCount(for count: Int, rows: Int) -> Int {
guard rows > 0 else { return count }
return Int(ceil(Double(count) / Double(rows)))
}
private static func splitRows(for buttons: [NSButton], rowCount: Int) -> [[NSButton]] {
guard rowCount > 1 else { return [buttons] }
let base = buttons.count / rowCount
let extra = buttons.count % rowCount
var rows: [[NSButton]] = []
var start = 0
for index in 0..<rowCount {
let size = base + (index < extra ? 1 : 0)
if size == 0 {
rows.append([])
continue
}
let end = min(buttons.count, start + size)
rows.append(Array(buttons[start..<end]))
start = end
}
return rows
}
private static func switcherButtonHeight(stackedIcons: Bool, rowCount: Int) -> CGFloat {
guard stackedIcons else { return 30 }
return rowCount >= 3 ? 39 : 36
}
private static func switcherOuterPadding(
for width: CGFloat,
count: Int,
minimumGap: CGFloat,
requiredSegmentWidth: CGFloat? = nil) -> CGFloat
{
// Align with the card's left/right content grid when possible.
let preferred: CGFloat = 16
let reduced: CGFloat = 10
let minimal: CGFloat = 6
func averageButtonWidth(outerPadding: CGFloat) -> CGFloat {
let available = width - outerPadding * 2 - minimumGap * CGFloat(max(0, count - 1))
guard count > 0 else { return 0 }
return available / CGFloat(count)
}
// Only sacrifice padding when we'd otherwise squeeze buttons into unreadable widths.
let minimumComfortableAverage: CGFloat = count >= 5 ? 50 : 54
func fits(outerPadding: CGFloat) -> Bool {
if let requiredSegmentWidth {
let allowedWidth = self.maxAllowedUniformSegmentWidth(
for: width,
count: count,
outerPadding: outerPadding,
minimumGap: minimumGap)
let evenAllowedWidth = allowedWidth.truncatingRemainder(dividingBy: 2) == 0
? allowedWidth
: allowedWidth - 1
let desiredWidth = ceil(requiredSegmentWidth)
let evenDesiredWidth = desiredWidth.truncatingRemainder(dividingBy: 2) == 0
? desiredWidth
: desiredWidth + 1
return evenAllowedWidth >= evenDesiredWidth
}
return averageButtonWidth(outerPadding: outerPadding) >= minimumComfortableAverage
}
if fits(outerPadding: preferred) { return preferred }
if fits(outerPadding: reduced) { return reduced }
return minimal
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
override var intrinsicContentSize: NSSize {
NSSize(width: self.preferredWidth, height: self.frame.size.height)
}
func updateSelection(_ selection: ProviderSwitcherSelection) {
var selectedIndex: Int?
for (index, button) in self.buttons.enumerated() {
let isSelected = self.segments.indices.contains(index) && self.segments[index].selection == selection
if isSelected {
selectedIndex = index
}
button.state = isSelected ? .on : .off
}
self.selectedSegmentIndex = selectedIndex
self.updateButtonStyles()
}
func updateQuotaIndicators() {
CATransaction.begin()
CATransaction.setDisableActions(true)
defer { CATransaction.commit() }
for (index, button) in self.buttons.enumerated() {
guard self.segments.indices.contains(index) else { continue }
let segment = self.segments[index]
let remaining = self.remainingPercent(for: segment.selection)
let key = ObjectIdentifier(button)
if let remaining {
if var indicator = self.quotaIndicators[key] {
let newRatio = Self.quotaIndicatorRatio(remainingPercent: remaining)
if newRatio != indicator.fillRatio {
Self.updateQuotaIndicatorFill(
indicator: &indicator,
remainingPercent: remaining,
selection: segment.selection)
self.quotaIndicators[key] = indicator
}
} else {
self.addQuotaIndicator(to: button, selection: segment.selection, remainingPercent: remaining)
}
} else if let indicator = self.quotaIndicators.removeValue(forKey: key) {
indicator.track.removeFromSuperview()
continue
}
self.updateQuotaIndicatorVisibility(for: button)
}
}
private func remainingPercent(for selection: ProviderSwitcherSelection) -> Double? {
switch selection {
case let .provider(provider):
self.weeklyRemainingProvider(provider)
case .overview:
nil
}
}
@objc private func handleSelection(_ sender: NSButton) {
let index = sender.tag
guard self.segments.indices.contains(index) else { return }
self.applySelection(at: index)
}
private func updateButtonStyles() {
CATransaction.begin()
CATransaction.setDisableActions(true)
defer { CATransaction.commit() }
for button in self.buttons {
let isSelected = button.state == .on
let isHovered = self.hoveredButtonTag == button.tag
button.contentTintColor = isSelected ? self.selectedTextColor : self.unselectedTextColor
button.layer?.backgroundColor = if isSelected {
self.selectedBackground
} else if isHovered {
self.hoverPlateColor()
} else {
self.unselectedBackground
}
self.updateQuotaIndicatorVisibility(for: button)
(button as? StackedToggleButton)?.setContentTintColor(button.contentTintColor)
(button as? InlineIconToggleButton)?.setContentTintColor(button.contentTintColor)
}
}
private func isLightMode() -> Bool {
self.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua
}
private func hoverPlateColor() -> CGColor {
if self.isLightMode() {
return NSColor.black.withAlphaComponent(0.095).cgColor
}
return NSColor.labelColor.withAlphaComponent(0.06).cgColor
}
/// Cache for button width measurements to avoid repeated layout passes.
private static var buttonWidthCache: [ObjectIdentifier: CGFloat] = [:]
private static func maxToggleWidth(for button: NSButton) -> CGFloat {
let buttonId = ObjectIdentifier(button)
// Return cached value if available.
if let cached = buttonWidthCache[buttonId] {
return cached
}
let originalState = button.state
defer { button.state = originalState }
button.state = .off
button.layoutSubtreeIfNeeded()
let offWidth = button.fittingSize.width
button.state = .on
button.layoutSubtreeIfNeeded()
let onWidth = button.fittingSize.width
let maxWidth = max(offWidth, onWidth)
self.buttonWidthCache[buttonId] = maxWidth
return maxWidth
}
private static func clearButtonWidthCache() {
self.buttonWidthCache.removeAll()
}
private func applyUniformSegmentWidth(maxAllowedWidth: CGFloat) -> CGFloat {
guard !self.buttons.isEmpty else { return 0 }
var desiredWidths: [CGFloat] = []
desiredWidths.reserveCapacity(self.buttons.count)
for (index, button) in self.buttons.enumerated() {
if self.stackedIcons,
self.segments.indices.contains(index)
{
let font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
let titleWidth = ceil(
(self.segments[index].title as NSString).size(withAttributes: [.font: font])
.width)
let contentPadding: CGFloat = 4 + 4
let extraSlack: CGFloat = 1
desiredWidths.append(ceil(titleWidth + contentPadding + extraSlack))
} else {
desiredWidths.append(ceil(Self.maxToggleWidth(for: button)))
}
}
let maxDesired = desiredWidths.max() ?? 0
let evenMaxDesired = maxDesired.truncatingRemainder(dividingBy: 2) == 0 ? maxDesired : maxDesired + 1
let evenMaxAllowed = maxAllowedWidth > 0
? (maxAllowedWidth.truncatingRemainder(dividingBy: 2) == 0 ? maxAllowedWidth : maxAllowedWidth - 1)
: 0
let finalWidth: CGFloat = if evenMaxAllowed > 0 {
min(evenMaxDesired, evenMaxAllowed)
} else {
evenMaxDesired
}
if finalWidth > 0 {
for button in self.buttons {
button.widthAnchor.constraint(equalToConstant: finalWidth).isActive = true
}
}
return finalWidth
}
@discardableResult
private func applyNonUniformSegmentWidths(
totalWidth: CGFloat,
outerPadding: CGFloat,
minimumGap: CGFloat) -> [CGFloat]
{
guard !self.buttons.isEmpty else { return [] }
let count = self.buttons.count
let available = totalWidth -
outerPadding * 2 -
minimumGap * CGFloat(max(0, count - 1))
guard available > 0 else { return [] }
func evenFloor(_ value: CGFloat) -> CGFloat {
var v = floor(value)
if Int(v) % 2 != 0 { v -= 1 }
return v
}
let desired = self.buttons.map { ceil(Self.maxToggleWidth(for: $0)) }
let desiredSum = desired.reduce(0, +)
let avg = floor(available / CGFloat(count))
let minWidth = max(24, min(40, avg))
var widths: [CGFloat]
if desiredSum <= available {
widths = desired
} else {
let totalCapacity = max(0, desiredSum - minWidth * CGFloat(count))
if totalCapacity <= 0 {
widths = Array(repeating: available / CGFloat(count), count: count)
} else {
let overflow = desiredSum - available
widths = desired.map { desiredWidth in
let capacity = max(0, desiredWidth - minWidth)
let shrink = overflow * (capacity / totalCapacity)
return desiredWidth - shrink
}
}
}
widths = widths.map { max(minWidth, evenFloor($0)) }
var used = widths.reduce(0, +)
while available - used >= 2 {
if let best = widths.indices
.filter({ desired[$0] - widths[$0] >= 2 })
.max(by: { lhs, rhs in
(desired[lhs] - widths[lhs]) < (desired[rhs] - widths[rhs])
})
{
widths[best] += 2
used += 2
continue
}
guard let best = widths.indices.min(by: { lhs, rhs in widths[lhs] < widths[rhs] }) else { break }
widths[best] += 2
used += 2
}
for (index, button) in self.buttons.enumerated() where index < widths.count {
button.widthAnchor.constraint(equalToConstant: widths[index]).isActive = true
}
return widths
}
private static func maxAllowedUniformSegmentWidth(
for totalWidth: CGFloat,
count: Int,
outerPadding: CGFloat,
minimumGap: CGFloat) -> CGFloat
{
guard count > 0 else { return 0 }
let available = totalWidth -
outerPadding * 2 -
minimumGap * CGFloat(max(0, count - 1))
guard available > 0 else { return 0 }
return floor(available / CGFloat(count))
}
private static func paddedImage(_ image: NSImage, leading: CGFloat) -> NSImage {
let size = NSSize(width: image.size.width + leading, height: image.size.height)
let newImage = NSImage(size: size)
newImage.lockFocus()
let y = (size.height - image.size.height) / 2
image.draw(
at: NSPoint(x: leading, y: y),
from: NSRect(origin: .zero, size: image.size),
operation: .sourceOver,
fraction: 1.0)
newImage.unlockFocus()
newImage.isTemplate = image.isTemplate
return newImage
}
private static func overviewIcon() -> NSImage {
if let symbol = NSImage(systemSymbolName: "square.grid.2x2", accessibilityDescription: nil) {
return symbol
}
return NSImage(size: NSSize(width: 16, height: 16))
}
private static func switcherTitle(for provider: UsageProvider) -> String {
ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
}
}
extension ProviderSwitcherView {
private static func switcherLayoutMetrics(
for width: CGFloat,
count: Int,
minimumGap: CGFloat,
requiredSegmentWidth: CGFloat?) -> (outerPadding: CGFloat, maxAllowedSegmentWidth: CGFloat)
{
let outerPadding = self.switcherOuterPadding(
for: width,
count: count,
minimumGap: minimumGap,
requiredSegmentWidth: requiredSegmentWidth)
let maxAllowedSegmentWidth = self.maxAllowedUniformSegmentWidth(
for: width,
count: count,
outerPadding: outerPadding,
minimumGap: minimumGap)
return (outerPadding, maxAllowedSegmentWidth)
}
}
extension ProviderSwitcherView {
fileprivate func button(at location: NSPoint) -> NSButton? {
self.buttons.first { $0.frame.contains(location) }
}
}
#if DEBUG
extension ProviderSwitcherView {
func _test_mouseDownEvent(buttonTag: Int) -> NSEvent? {
self._test_mouseEvent(buttonTag: buttonTag, type: .leftMouseDown)
}
func _test_mouseUpEvent(buttonTag: Int) -> NSEvent? {
self._test_mouseEvent(buttonTag: buttonTag, type: .leftMouseUp)
}
func _test_quotaIndicatorMouseEvent(buttonTag: Int, type: NSEvent.EventType) -> NSEvent? {
guard let button = self.buttons.first(where: { $0.tag == buttonTag }),
let track = self.quotaIndicators[ObjectIdentifier(button)]?.track
else {
return nil
}
self.updateConstraintsForSubtreeIfNeeded()
self.layoutSubtreeIfNeeded()
let point = self.convert(NSPoint(x: track.bounds.midX, y: track.bounds.midY), from: track)
return self._test_mouseEvent(at: point, type: type)
}
private func _test_mouseEvent(buttonTag: Int, type: NSEvent.EventType) -> NSEvent? {
guard let button = self.buttons.first(where: { $0.tag == buttonTag }) else { return nil }
self.updateConstraintsForSubtreeIfNeeded()
self.layoutSubtreeIfNeeded()
let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button)
return self._test_mouseEvent(at: point, type: type)
}
private func _test_mouseEvent(at point: NSPoint, type: NSEvent.EventType) -> NSEvent? {
NSEvent.mouseEvent(
with: type,
location: point,
modifierFlags: [],
timestamp: 0,
windowNumber: 0,
context: nil,
eventNumber: type == .leftMouseDown ? 1 : 2,
clickCount: 1,
pressure: type == .leftMouseDown ? 1 : 0)
}
@discardableResult
func _test_simulateMouseDown(buttonTag: Int) -> Bool {
guard let event = self._test_mouseDownEvent(buttonTag: buttonTag) else { return false }
return self.handleMenuTrackingMouseDown(event)
}
/// Simulates the parent-view event path used while NSMenu owns mouse tracking.
@discardableResult
func _test_simulateRuntimeClick(buttonTag: Int) -> Bool {
guard self._test_simulateMouseDown(buttonTag: buttonTag) else { return false }
guard let event = self._test_mouseUpEvent(buttonTag: buttonTag) else { return false }
guard self.handleMenuTrackingMouseUp(event) else { return false }
return self.selectedSegmentIndex == buttonTag
}
@discardableResult
func _test_simulateRuntimeClickOnQuotaIndicator(buttonTag: Int) -> Bool {
guard let mouseDown = self._test_quotaIndicatorMouseEvent(buttonTag: buttonTag, type: .leftMouseDown),
self.handleMenuTrackingMouseDown(mouseDown),
let mouseUp = self._test_quotaIndicatorMouseEvent(buttonTag: buttonTag, type: .leftMouseUp),
self.handleMenuTrackingMouseUp(mouseUp)
else {
return false
}
return self.selectedSegmentIndex == buttonTag
}
@discardableResult
func _test_simulateNativeAction(buttonTag: Int, state: NSControl.StateValue) -> Bool {
guard let button = self.buttons.first(where: { $0.tag == buttonTag }) else { return false }
button.state = state
self.handleSelection(button)
return true
}
func _test_buttonFrames() -> [NSRect] {
self.buttons.map(\.frame)
}
func _test_buttonFittingSizes() -> [NSSize] {
self.buttons.map(\.fittingSize)
}
func _test_buttonDesiredWidths() -> [CGFloat] {
self.buttons.map(Self.maxToggleWidth(for:))
}
func _test_buttonContentFrames() -> [NSRect?] {
self.buttons.map { button in
button.subviews.first(where: { $0 is NSStackView })?.frame
}
}
func _test_rowCount() -> Int {
self.rowCount
}
func _test_rowHeight() -> CGFloat {
self.rowHeight
}
func _test_setHoveredButtonTag(_ tag: Int?) {
self.hoveredButtonTag = tag
self.updateButtonStyles()
}
func _test_quotaIndicatorFillRatios() -> [CGFloat] {
self.buttons.compactMap { button in
self.quotaIndicators[ObjectIdentifier(button)]?.fillRatio
}
}
func _test_quotaIndicatorFillFrames() -> [NSRect] {
self.buttons.compactMap { button in
self.quotaIndicators[ObjectIdentifier(button)]?.fill.frame
}
}
func _test_quotaIndicatorTrackFrames() -> [NSRect] {
self.buttons.compactMap { button in
guard let track = self.quotaIndicators[ObjectIdentifier(button)]?.track else { return nil }
return self.convert(track.bounds, from: track)
}
}
func _test_quotaIndicatorConstraintIdentifiers() -> [ObjectIdentifier] {
self.buttons.compactMap { button in
self.quotaIndicators[ObjectIdentifier(button)].map { ObjectIdentifier($0.fillWidthConstraint) }
}
}
}
#endif
extension ProviderSwitcherView {
private func addQuotaIndicator(to view: NSView, selection: ProviderSwitcherSelection, remainingPercent: Double?) {
guard let remainingPercent else { return }
let track = NSView()
track.wantsLayer = true
track.layer?.backgroundColor = NSColor.tertiaryLabelColor.withAlphaComponent(0.22).cgColor
track.layer?.cornerRadius = Self.quotaIndicatorHeight / 2
track.layer?.masksToBounds = true
track.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(track)
let fill = NSView()
fill.wantsLayer = true
fill.layer?.backgroundColor = Self.quotaIndicatorColor(
for: selection,
remainingPercent: remainingPercent).cgColor
fill.layer?.cornerRadius = Self.quotaIndicatorHeight / 2
fill.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
fill.translatesAutoresizingMaskIntoConstraints = false
track.addSubview(fill)
let ratio = Self.quotaIndicatorRatio(remainingPercent: remainingPercent)
let fillWidthConstraint = Self.quotaIndicatorFillWidthConstraint(fill: fill, track: track, ratio: ratio)
NSLayoutConstraint.activate([
track.leadingAnchor.constraint(
equalTo: view.leadingAnchor,
constant: Self.quotaIndicatorHorizontalInset),
track.trailingAnchor.constraint(
equalTo: view.trailingAnchor,
constant: -Self.quotaIndicatorHorizontalInset),
track.bottomAnchor.constraint(
equalTo: view.bottomAnchor,
constant: -Self.quotaIndicatorBottomInset),
track.heightAnchor.constraint(equalToConstant: Self.quotaIndicatorHeight),
fill.leadingAnchor.constraint(equalTo: track.leadingAnchor),
fill.topAnchor.constraint(equalTo: track.topAnchor),
fill.bottomAnchor.constraint(equalTo: track.bottomAnchor),
fillWidthConstraint,
])
self.quotaIndicators[ObjectIdentifier(view)] = QuotaIndicator(
track: track,
fill: fill,
fillWidthConstraint: fillWidthConstraint,
fillRatio: ratio)
self.updateQuotaIndicatorVisibility(for: view)
}
private func updateQuotaIndicatorVisibility(for view: NSView) {
guard let indicator = self.quotaIndicators[ObjectIdentifier(view)] else { return }
let isSelected = (view as? NSButton)?.state == .on
indicator.track.isHidden = isSelected
indicator.fill.isHidden = isSelected || indicator.fillRatio <= 0
}
fileprivate static func updateQuotaIndicatorFill(
indicator: inout QuotaIndicator,
remainingPercent: Double,
selection: ProviderSwitcherSelection)
{
let ratio = Self.quotaIndicatorRatio(remainingPercent: remainingPercent)
indicator.fillWidthConstraint.isActive = false
let fillWidthConstraint = Self.quotaIndicatorFillWidthConstraint(
fill: indicator.fill,
track: indicator.track,
ratio: ratio)
fillWidthConstraint.isActive = true
indicator.fillWidthConstraint = fillWidthConstraint
indicator.fillRatio = ratio
indicator.fill.layer?.backgroundColor = Self.quotaIndicatorColor(
for: selection,
remainingPercent: remainingPercent).cgColor
indicator.fill.layer?.cornerRadius = Self.quotaIndicatorHeight / 2
indicator.fill.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
indicator.track.isHidden = false
indicator.fill.isHidden = ratio <= 0
}
fileprivate static func quotaIndicatorColor(
for selection: ProviderSwitcherSelection,
remainingPercent _: Double) -> NSColor
{
switch selection {
case let .provider(provider):
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
return NSColor(deviceRed: color.red, green: color.green, blue: color.blue, alpha: 1)
case .overview:
return NSColor.secondaryLabelColor
}
}
fileprivate static func quotaIndicatorRatio(remainingPercent: Double) -> CGFloat {
CGFloat(max(0, min(1, remainingPercent / 100)))
}
private static func quotaIndicatorFillWidthConstraint(
fill: NSView,
track: NSView,
ratio: CGFloat)
-> NSLayoutConstraint
{
guard ratio > 0 else {
return fill.widthAnchor.constraint(equalToConstant: 0)
}
return fill.widthAnchor.constraint(equalTo: track.widthAnchor, multiplier: ratio)
}
}
final class TokenAccountSwitcherView: NSView {
private let accounts: [ProviderTokenAccount]
private let onSelect: (Int) -> Task<Void, Never>?
private var selectedIndex: Int
private var buttons: [NSButton] = []
private let preferredSize: NSSize
private let rowSpacing: CGFloat = 4
private let rowHeight: CGFloat = 26
private let selectedBackground = NSColor.controlAccentColor.cgColor
private let unselectedBackground = NSColor.clear.cgColor
private let selectedTextColor = NSColor.white
private let unselectedTextColor = NSColor.secondaryLabelColor
init(
accounts: [ProviderTokenAccount],
selectedIndex: Int,
width: CGFloat,
onSelect: @escaping (Int) -> Task<Void, Never>?)
{
self.accounts = accounts
self.onSelect = onSelect
self.selectedIndex = min(max(selectedIndex, 0), max(0, accounts.count - 1))
let useTwoRows = accounts.count > 3
let rows = useTwoRows ? 2 : 1
let height = self.rowHeight * CGFloat(rows) + (useTwoRows ? self.rowSpacing : 0)
self.preferredSize = NSSize(width: width, height: height)
super.init(frame: NSRect(x: 0, y: 0, width: width, height: height))
self.wantsLayer = true
self.buildButtons(useTwoRows: useTwoRows)
self.updateButtonStyles()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
override var intrinsicContentSize: NSSize {
self.preferredSize
}
override var fittingSize: NSSize {
self.preferredSize
}
private func buildButtons(useTwoRows: Bool) {
let perRow = useTwoRows ? Int(ceil(Double(self.accounts.count) / 2.0)) : self.accounts.count
let rows: [[ProviderTokenAccount]] = {
if !useTwoRows { return [self.accounts] }
let first = Array(self.accounts.prefix(perRow))
let second = Array(self.accounts.dropFirst(perRow))
return [first, second]
}()
let stack = NSStackView()
stack.orientation = .vertical
stack.alignment = .width
stack.spacing = self.rowSpacing
stack.translatesAutoresizingMaskIntoConstraints = false
var globalIndex = 0
for rowAccounts in rows {
let row = NSStackView()
row.orientation = .horizontal
row.alignment = .centerY
row.distribution = .fillEqually
row.spacing = self.rowSpacing
row.translatesAutoresizingMaskIntoConstraints = false
for account in rowAccounts {
let button = PaddedToggleButton(
title: account.displayName,
target: self,
action: #selector(self.handleSelect))
button.tag = globalIndex
button.toolTip = account.displayName
button.isBordered = false
button.setButtonType(.toggle)
button.controlSize = .small
button.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
button.cell?.lineBreakMode = account.displayName.contains("@") ? .byTruncatingMiddle : .byTruncatingTail
button.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
button.wantsLayer = true
button.layer?.cornerRadius = 6
row.addArrangedSubview(button)
self.buttons.append(button)
globalIndex += 1
}
stack.addArrangedSubview(row)
row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
self.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6),
stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6),
stack.topAnchor.constraint(equalTo: self.topAnchor),
stack.bottomAnchor.constraint(equalTo: self.bottomAnchor),
stack.heightAnchor.constraint(equalToConstant: self.rowHeight * CGFloat(rows.count) +
(useTwoRows ? self.rowSpacing : 0)),
])
}
private func updateButtonStyles() {
for (index, button) in self.buttons.enumerated() {
let selected = index == self.selectedIndex
button.state = selected ? .on : .off
button.layer?.backgroundColor = selected ? self.selectedBackground : self.unselectedBackground
button.contentTintColor = selected ? self.selectedTextColor : self.unselectedTextColor
}
}
@objc private func handleSelect(_ sender: NSButton) {
_ = self.select(index: sender.tag)
}
@discardableResult
private func select(index: Int) -> Task<Void, Never>? {
guard index >= 0, index < self.accounts.count else { return nil }
self.selectedIndex = index
self.updateButtonStyles()
return self.onSelect(index)
}
#if DEBUG
func _test_select(index: Int) -> Task<Void, Never>? {
guard let button = self.buttons.first(where: { $0.tag == index }) else { return nil }
return self.select(index: button.tag)
}
func _test_buttonTitles() -> [String] {
self.buttons.map(\.title)
}
#endif
}
final class CodexAccountSwitcherView: NSView {
private let accounts: [CodexVisibleAccount]
private let onSelect: (CodexVisibleAccount) -> Void
private var selectedAccountID: String
private var pressedAccountID: String?
private var buttons: [NSButton] = []
private let preferredSize: NSSize
private let rowSpacing: CGFloat = 4
private let rowHeight: CGFloat = 26
private let selectedBackground = NSColor.controlAccentColor.cgColor
private let unselectedBackground = NSColor.clear.cgColor
private let selectedTextColor = NSColor.white
private let unselectedTextColor = NSColor.secondaryLabelColor
private let buttonFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
private let buttonHorizontalPadding: CGFloat = 14
private let buttonSideInset: CGFloat = 6
init(
accounts: [CodexVisibleAccount],
selectedAccountID: String?,
width: CGFloat,
onSelect: @escaping (CodexVisibleAccount) -> Void)
{
self.accounts = accounts
self.onSelect = onSelect
self.selectedAccountID = selectedAccountID ?? accounts.first?.id ?? ""
let useTwoRows = accounts.count > 3
let rows = useTwoRows ? 2 : 1
let height = self.rowHeight * CGFloat(rows) + (useTwoRows ? self.rowSpacing : 0)
self.preferredSize = NSSize(width: width, height: height)
super.init(frame: NSRect(x: 0, y: 0, width: width, height: height))
self.wantsLayer = true
self.buildButtons(useTwoRows: useTwoRows)
self.updateButtonStyles()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
override var intrinsicContentSize: NSSize {
self.preferredSize
}
override var fittingSize: NSSize {
self.preferredSize
}
private func buildButtons(useTwoRows: Bool) {
let perRow = useTwoRows ? Int(ceil(Double(self.accounts.count) / 2.0)) : self.accounts.count
let rows: [[CodexVisibleAccount]] = {
if !useTwoRows { return [self.accounts] }
let first = Array(self.accounts.prefix(perRow))
let second = Array(self.accounts.dropFirst(perRow))
return [first, second]
}()
let stack = NSStackView()
stack.orientation = .vertical
stack.alignment = .width
stack.spacing = self.rowSpacing
stack.translatesAutoresizingMaskIntoConstraints = false
for rowAccounts in rows {
let row = NSStackView()
row.orientation = .horizontal
row.alignment = .centerY
row.distribution = .fillEqually
row.spacing = self.rowSpacing
row.translatesAutoresizingMaskIntoConstraints = false
let buttonWidth = self.buttonWidth(for: rowAccounts.count)
for account in rowAccounts {
let title = self.compactButtonTitle(for: account, buttonWidth: buttonWidth)
let button = PaddedToggleButton(
title: title,
target: self,
action: #selector(self.handleSelect))
button.identifier = NSUserInterfaceItemIdentifier(account.id)
button.toolTip = account.menuDisplayName
button.isBordered = false
button.setButtonType(.toggle)
button.controlSize = .small
button.font = self.buttonFont
button.cell?.lineBreakMode = .byTruncatingTail
button.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
button.wantsLayer = true
button.layer?.cornerRadius = 6
row.addArrangedSubview(button)
self.buttons.append(button)
}
stack.addArrangedSubview(row)
row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
self.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: self.buttonSideInset),
stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -self.buttonSideInset),
stack.topAnchor.constraint(equalTo: self.topAnchor),
stack.bottomAnchor.constraint(equalTo: self.bottomAnchor),
stack.heightAnchor.constraint(equalToConstant: self.rowHeight * CGFloat(rows.count) +
(useTwoRows ? self.rowSpacing : 0)),
])
}
private func buttonWidth(for count: Int) -> CGFloat {
let contentWidth = self.bounds.width - (self.buttonSideInset * 2)
let spacing = self.rowSpacing * CGFloat(max(0, count - 1))
guard count > 0 else { return contentWidth }
return max(44, floor((contentWidth - spacing) / CGFloat(count)))
}
private func compactButtonTitle(for account: CodexVisibleAccount, buttonWidth: CGFloat) -> String {
let availableTextWidth = max(24, buttonWidth - self.buttonHorizontalPadding)
if self.textWidth(account.menuDisplayName) <= availableTextWidth {
return account.menuDisplayName
}
guard let workspace = account.menuWorkspaceLabel else {
return self.truncateMiddle(account.email, toFit: availableTextWidth)
}
let separator = "|"
let separatorWidth = self.textWidth(separator)
let contentWidth = max(24, availableTextWidth - separatorWidth)
let minimumEmailWidth = min(contentWidth * 0.45, max(18, contentWidth * 0.3))
let minimumWorkspaceWidth = min(contentWidth * 0.4, max(18, contentWidth * 0.25))
var emailWidth = max(minimumEmailWidth, contentWidth * 0.58)
var workspaceWidth = max(minimumWorkspaceWidth, contentWidth - emailWidth)
func makeTitle() -> String {
let email = self.truncateMiddle(account.email, toFit: emailWidth)
let workspace = self.truncateTail(workspace, toFit: workspaceWidth)
return "\(email)\(separator)\(workspace)"
}
var title = makeTitle()
var attempts = 0
while self.textWidth(title) > availableTextWidth, attempts < 16 {
let emailText = self.truncateMiddle(account.email, toFit: emailWidth)
let workspaceText = self.truncateTail(workspace, toFit: workspaceWidth)
let emailRenderedWidth = self.textWidth(emailText)
let workspaceRenderedWidth = self.textWidth(workspaceText)
if emailRenderedWidth >= workspaceRenderedWidth, emailWidth > minimumEmailWidth {
emailWidth = max(minimumEmailWidth, emailWidth - 6)
} else if workspaceWidth > minimumWorkspaceWidth {
workspaceWidth = max(minimumWorkspaceWidth, workspaceWidth - 6)
} else {
break
}
title = makeTitle()
attempts += 1
}
return title
}
private func truncateTail(_ text: String, toFit width: CGFloat) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return text }
if self.textWidth(trimmed) <= width {
return trimmed
}
let ellipsis = "…"
let ellipsisWidth = self.textWidth(ellipsis)
guard ellipsisWidth < width else { return ellipsis }
var candidate = ""
for character in trimmed {
let next = candidate + String(character)
if self.textWidth(next + ellipsis) > width {
break
}
candidate = next
}
if candidate.isEmpty {
return ellipsis
}
return candidate + ellipsis
}
private func truncateMiddle(_ text: String, toFit width: CGFloat) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return text }
if self.textWidth(trimmed) <= width {
return trimmed
}
let ellipsis = "…"
let ellipsisWidth = self.textWidth(ellipsis)
guard ellipsisWidth < width else { return ellipsis }
var prefix = ""
var suffix = ""
var prefixIndex = trimmed.startIndex
var suffixIndex = trimmed.endIndex
var best = ellipsis
var takeSuffixNext = true
while prefixIndex < suffixIndex {
let nextPrefix: String
let nextSuffix: String
if takeSuffixNext {
let previousIndex = trimmed.index(before: suffixIndex)
nextPrefix = prefix
nextSuffix = String(trimmed[previousIndex]) + suffix
suffixIndex = previousIndex
} else {
nextPrefix = prefix + String(trimmed[prefixIndex])
nextSuffix = suffix
prefixIndex = trimmed.index(after: prefixIndex)
}
let candidate = nextPrefix + ellipsis + nextSuffix
if self.textWidth(candidate) > width {
break
}
prefix = nextPrefix
suffix = nextSuffix
best = candidate
takeSuffixNext.toggle()
}
return best
}
private func textWidth(_ text: String) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [.font: self.buttonFont]
return ceil((text as NSString).size(withAttributes: attributes).width)
}
private func updateButtonStyles() {
for button in self.buttons {
let selected = button.identifier?.rawValue == self.selectedAccountID
button.state = selected ? .on : .off
button.layer?.backgroundColor = selected ? self.selectedBackground : self.unselectedBackground
button.contentTintColor = selected ? self.selectedTextColor : self.unselectedTextColor
}
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}
override func hitTest(_ point: NSPoint) -> NSView? {
let descendant = super.hitTest(point)
if descendant != nil, descendant !== self {
self.toolTip = (descendant as? NSButton)?.toolTip
return self
}
self.toolTip = nil
return descendant
}
override func mouseDown(with event: NSEvent) {
let location = self.convert(event.locationInWindow, from: nil)
self.pressedAccountID = self.accountID(at: location)
}
override func mouseUp(with event: NSEvent) {
defer { self.pressedAccountID = nil }
guard let pressedAccountID = self.pressedAccountID else { return }
let location = self.convert(event.locationInWindow, from: nil)
guard let releasedAccountID = self.accountID(at: location),
releasedAccountID == pressedAccountID,
let account = self.accounts.first(where: { $0.id == pressedAccountID })
else {
return
}
self.applySelection(account)
}
private func accountID(at pointInSelf: NSPoint) -> String? {
self.buttons.first(where: { self.convert($0.bounds, from: $0).contains(pointInSelf) })?.identifier?.rawValue
}
@objc private func handleSelect(_ sender: NSButton) {
guard let accountID = sender.identifier?.rawValue,
let account = self.accounts.first(where: { $0.id == accountID }) else { return }
self.applySelection(account)
}
private func applySelection(_ account: CodexVisibleAccount) {
self.selectedAccountID = account.id
self.updateButtonStyles()
self.onSelect(account)
}
#if DEBUG
func _test_buttonTitles() -> [String] {
self.buttons.map(\.title)
}
func _test_buttonToolTips() -> [String?] {
self.buttons.map(\.toolTip)
}
func _test_selectAccount(id: String) {
guard let account = self.accounts.first(where: { $0.id == id }) else { return }
self.applySelection(account)
}
func _test_simulateRuntimeClick(id: String) -> Bool {
guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false }
self.updateConstraintsForSubtreeIfNeeded()
self.layoutSubtreeIfNeeded()
let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button)
guard let mouseDownEvent = NSEvent.mouseEvent(
with: .leftMouseDown,
location: point,
modifierFlags: [],
timestamp: 0,
windowNumber: 0,
context: nil,
eventNumber: 1,
clickCount: 1,
pressure: 1),
let mouseUpEvent = NSEvent.mouseEvent(
with: .leftMouseUp,
location: point,
modifierFlags: [],
timestamp: 0,
windowNumber: 0,
context: nil,
eventNumber: 2,
clickCount: 1,
pressure: 0)
else {
return false
}
self.mouseDown(with: mouseDownEvent)
self.mouseUp(with: mouseUpEvent)
return self.selectedAccountID == id
}
func _test_hitTestSwallowsChildButton(id: String) -> Bool {
guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false }
self.updateConstraintsForSubtreeIfNeeded()
self.layoutSubtreeIfNeeded()
let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button)
return self.hitTest(point) === self
}
func _test_toolTipAfterHitTest(id: String) -> String? {
guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return nil }
self.updateConstraintsForSubtreeIfNeeded()
self.layoutSubtreeIfNeeded()
let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button)
_ = self.hitTest(point)
return self.toolTip
}
#endif
}