1670 lines
64 KiB
Swift
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
|
|
}
|