import AppKitimport CodexBarCoreimport 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 let lightModeOverlayLayer = CALayer() 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 self.lightModeOverlayLayer.masksToBounds = false self.layer?.insertSublayer(self.lightModeOverlayLayer, at: 0) self.updateLightModeStyling() 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 layout() { super.layout() self.lightModeOverlayLayer.frame = self.bounds } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() self.updateLightModeStyling() 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))Lines 1–500 of 1688. Load more
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 let lightModeOverlayLayer = CALayer() 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 self.lightModeOverlayLayer.masksToBounds = false self.layer?.insertSublayer(self.lightModeOverlayLayer, at: 0) self.updateLightModeStyling() 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 layout() { super.layout() self.lightModeOverlayLayer.frame = self.bounds } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() self.updateLightModeStyling() 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))