diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index 08f4b3ba..854d9a83 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -137,6 +137,11 @@ enum TabBarColors { splitButtonBackdropColor(for: appearance) ?? nsColorBarBackground(for: appearance) } + static func nsColorSplitButtonBackdropOccludingSurface(for appearance: BonsplitConfiguration.Appearance) -> NSColor { + nonClearColor(splitButtonBackdropColor(for: appearance)) + ?? .clear + } + static func nsColorSplitButtonBackdrop( for appearance: BonsplitConfiguration.Appearance, focused: Bool = true @@ -145,9 +150,7 @@ enum TabBarColors { } static func shouldPaintSplitButtonBackdrop(for appearance: BonsplitConfiguration.Appearance) -> Bool { - splitButtonBackdropColor(for: appearance) != nil - || tabBarBackgroundColor(for: appearance) != nil - || chromeBackgroundColor(for: appearance) != nil + nonClearColor(splitButtonBackdropColor(for: appearance)) != nil } static var barMaterial: Material { diff --git a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 5e0a2577..6fef8e94 100644 --- a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -299,7 +299,7 @@ struct PaneContainerView: View { .allowsHitTesting(!isTabDragActive) Color.clear - .onDrop(of: [.tabTransfer], delegate: UnifiedPaneDropDelegate( + .onDrop(of: [.tabTransfer, .fileURL], delegate: UnifiedPaneDropDelegate( size: size, pane: pane, controller: controller, @@ -351,6 +351,9 @@ struct UnifiedPaneDropDelegate: DropDelegate { private func effectiveZone(for info: DropInfo) -> DropZone { let defaultZone = zoneForLocation(info.location) + guard !isFileDropOnly(info) else { + return defaultZone + } guard let draggedTab = controller.activeDragTab ?? controller.draggingTab, let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else { return defaultZone @@ -404,24 +407,66 @@ struct UnifiedPaneDropDelegate: DropDelegate { ) #endif - // Read from non-observable drag state — @Observable writes from createItemProvider + let hasTabTransfer = info.hasItemsConforming(to: [.tabTransfer]) + let hasFileURL = info.hasItemsConforming(to: [.fileURL]) + + // Read from non-observable drag state. @Observable writes from createItemProvider // may not have propagated yet when performDrop runs. - guard let draggedTab = controller.activeDragTab ?? controller.draggingTab, - let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else { - guard let transfer = decodeTransfer(from: info), - transfer.isFromCurrentProcess else { - return false - } - let destination: BonsplitController.ExternalTabDropRequest.Destination + if Self.shouldUseLocalTabDrag( + hasTabTransfer: hasTabTransfer, + hasFileURL: hasFileURL, + hasLocalTabDrag: controller.activeDragTab != nil || controller.draggingTab != nil + ), + let draggedTab = controller.activeDragTab ?? controller.draggingTab, + let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId { + // Clear both observable and non-observable drag state. + dropLifecycle = .idle + activeDropZone = nil + controller.draggingTab = nil + controller.dragSourcePaneId = nil + controller.activeDragTab = nil + controller.activeDragSourcePaneId = nil + if zone == .center { - destination = .insert(targetPane: pane.id, targetIndex: nil) + if sourcePaneId != pane.id { + withTransaction(Transaction(animation: nil)) { + _ = bonsplitController.moveTab( + TabID(id: draggedTab.id), + toPane: pane.id, + atIndex: nil + ) + } + } } else if let orientation = zone.orientation { - destination = .split( - targetPane: pane.id, +#if DEBUG + dlog( + "pane.drop.splitRequest targetPane=\(pane.id.id.uuidString.prefix(5)) " + + "sourcePane=\(sourcePaneId.id.uuidString.prefix(5)) zone=\(zone) " + + "orientation=\(orientation) insertFirst=\(zone.insertsFirst ? 1 : 0) " + + "draggedTab=\(draggedTab.id.uuidString.prefix(5))" + ) +#endif + let newPaneId = bonsplitController.splitPane( + pane.id, orientation: orientation, + movingTab: TabID(id: draggedTab.id), insertFirst: zone.insertsFirst ) - } else { +#if DEBUG + dlog( + "pane.drop.splitResult targetPane=\(pane.id.id.uuidString.prefix(5)) " + + "newPane=\(newPaneId?.id.uuidString.prefix(5) ?? "nil")" + ) +#endif + } + + return true + } + + if info.hasItemsConforming(to: [.tabTransfer]) { + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess, + let destination = destination(for: zone) else { return false } @@ -438,48 +483,13 @@ struct UnifiedPaneDropDelegate: DropDelegate { return handled } - // Clear both observable and non-observable drag state. - dropLifecycle = .idle - activeDropZone = nil - controller.draggingTab = nil - controller.dragSourcePaneId = nil - controller.activeDragTab = nil - controller.activeDragSourcePaneId = nil - - if zone == .center { - if sourcePaneId != pane.id { - withTransaction(Transaction(animation: nil)) { - _ = bonsplitController.moveTab( - TabID(id: draggedTab.id), - toPane: pane.id, - atIndex: nil - ) - } - } - } else if let orientation = zone.orientation { -#if DEBUG - dlog( - "pane.drop.splitRequest targetPane=\(pane.id.id.uuidString.prefix(5)) " + - "sourcePane=\(sourcePaneId.id.uuidString.prefix(5)) zone=\(zone) " + - "orientation=\(orientation) insertFirst=\(zone.insertsFirst ? 1 : 0) " + - "draggedTab=\(draggedTab.id.uuidString.prefix(5))" - ) -#endif - let newPaneId = bonsplitController.splitPane( - pane.id, - orientation: orientation, - movingTab: TabID(id: draggedTab.id), - insertFirst: zone.insertsFirst - ) -#if DEBUG - dlog( - "pane.drop.splitResult targetPane=\(pane.id.id.uuidString.prefix(5)) " + - "newPane=\(newPaneId?.id.uuidString.prefix(5) ?? "nil")" - ) -#endif + guard info.hasItemsConforming(to: [.fileURL]) else { return false } + let handled = performFileDrop(info: info, zone: zone) + if handled { + dropLifecycle = .idle + activeDropZone = nil } - - return true + return handled } func dropEntered(info: DropInfo) { @@ -509,14 +519,26 @@ struct UnifiedPaneDropDelegate: DropDelegate { #if DEBUG dlog("pane.dropUpdated.skip pane=\(pane.id.id.uuidString.prefix(5)) reason=lifecycle_idle") #endif - return DropProposal(operation: .move) + return DropProposal(operation: dropOperation(for: info)) } let zone = effectiveZone(for: info) - activeDropZone = zone + guard let acceptedZone = Self.acceptedDropZone( + zone, + isFileDropOnly: isFileDropOnly(info), + hasExternalFileDropHandler: bonsplitController.onExternalFileDrop != nil, + hasLegacyFileDropHandler: controller.onFileDrop != nil + ) else { + activeDropZone = nil #if DEBUG - dlog("pane.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) zone=\(zone)") + dlog("pane.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) zone=\(zone) allowed=0") #endif - return DropProposal(operation: .move) + return DropProposal(operation: .cancel) + } + activeDropZone = acceptedZone +#if DEBUG + dlog("pane.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) zone=\(acceptedZone)") +#endif + return DropProposal(operation: dropOperation(for: info)) } func validateDrop(info: DropInfo) -> Bool { @@ -527,33 +549,155 @@ struct UnifiedPaneDropDelegate: DropDelegate { #endif return false } - // The custom UTType alone is sufficient — only Bonsplit tab drags produce it. - // Do NOT gate on draggingTab != nil: @Observable changes from createItemProvider - // may not have propagated to the drop delegate yet, causing false rejections. - let hasType = info.hasItemsConforming(to: [.tabTransfer]) - guard hasType else { return false } - - // Local drags use in-memory state and are always same-process. - if controller.activeDragTab != nil || controller.draggingTab != nil { + let hasTabTransfer = info.hasItemsConforming(to: [.tabTransfer]) + let hasFileURL = info.hasItemsConforming(to: [.fileURL]) + guard hasTabTransfer || hasFileURL else { return false } + + if Self.isFileDropOnly(hasTabTransfer: hasTabTransfer, hasFileURL: hasFileURL) { + guard Self.acceptsFileDrop( + zone: effectiveZone(for: info), + hasExternalFileDropHandler: bonsplitController.onExternalFileDrop != nil, + hasLegacyFileDropHandler: controller.onFileDrop != nil + ), Self.hasReadableFileURLs() else { + return false + } + } else if controller.activeDragTab != nil || controller.draggingTab != nil { + // Local tab drags use in-memory state and are always same-process. return true - } - - // External drags (another Bonsplit controller) must include a payload from this process. - guard let transfer = decodeTransfer(from: info), - transfer.isFromCurrentProcess else { - return false + } else if hasTabTransfer { + // External drags (another Bonsplit controller) must include a payload from this process. + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess else { + return false + } } #if DEBUG let hasDrag = controller.draggingTab != nil let hasActive = controller.activeDragTab != nil dlog( "pane.validateDrop pane=\(pane.id.id.uuidString.prefix(5)) " + - "allowed=\(hasType ? 1 : 0) hasDrag=\(hasDrag ? 1 : 0) hasActive=\(hasActive ? 1 : 0)" + "hasTab=\(hasTabTransfer ? 1 : 0) hasFile=\(hasFileURL ? 1 : 0) " + + "hasDrag=\(hasDrag ? 1 : 0) hasActive=\(hasActive ? 1 : 0)" ) #endif return true } + static func acceptsFileDrop( + zone: DropZone, + hasExternalFileDropHandler: Bool, + hasLegacyFileDropHandler: Bool + ) -> Bool { + if hasExternalFileDropHandler { + return true + } + guard hasLegacyFileDropHandler else { + return false + } + return zone == .center + } + + static func acceptedDropZone( + _ zone: DropZone, + isFileDropOnly: Bool, + hasExternalFileDropHandler: Bool, + hasLegacyFileDropHandler: Bool + ) -> DropZone? { + if isFileDropOnly, + !acceptsFileDrop( + zone: zone, + hasExternalFileDropHandler: hasExternalFileDropHandler, + hasLegacyFileDropHandler: hasLegacyFileDropHandler + ) { + return nil + } + return zone + } + + private func dropOperation(for info: DropInfo) -> DropOperation { + isFileDropOnly(info) + ? .copy + : .move + } + + private func isFileDropOnly(_ info: DropInfo) -> Bool { + Self.isFileDropOnly( + hasTabTransfer: info.hasItemsConforming(to: [.tabTransfer]), + hasFileURL: info.hasItemsConforming(to: [.fileURL]) + ) + } + + static func isFileDropOnly(hasTabTransfer: Bool, hasFileURL: Bool) -> Bool { + hasFileURL && !hasTabTransfer + } + + static func shouldUseLocalTabDrag( + hasTabTransfer: Bool, + hasFileURL: Bool, + hasLocalTabDrag: Bool + ) -> Bool { + hasLocalTabDrag && !isFileDropOnly(hasTabTransfer: hasTabTransfer, hasFileURL: hasFileURL) + } + + private func destination( + for zone: DropZone + ) -> BonsplitController.ExternalTabDropRequest.Destination? { + if zone == .center { + return .insert(targetPane: pane.id, targetIndex: nil) + } + guard let orientation = zone.orientation else { return nil } + return .split( + targetPane: pane.id, + orientation: orientation, + insertFirst: zone.insertsFirst + ) + } + + private func performFileDrop(info: DropInfo, zone: DropZone) -> Bool { + guard let destination = destination(for: zone) else { return false } + let urls = fileURLs(from: info) + guard !urls.isEmpty else { return false } + + if let handler = bonsplitController.onExternalFileDrop { + return handler(BonsplitController.ExternalFileDropRequest(urls: urls, destination: destination)) + } + + if case .insert = destination { + return controller.onFileDrop?(urls, pane.id) ?? false + } + return false + } + + private func fileURLs(from info: DropInfo) -> [URL] { + guard info.hasItemsConforming(to: [.fileURL]) else { return [] } + + // DropInfo exposes file URLs through asynchronous item providers, but + // DropDelegate.performDrop needs a synchronous result. AppKit publishes + // Finder/local file drags on the drag pasteboard for this callback. + return Self.fileURLs(from: NSPasteboard(name: .drag)) + } + + static func fileURLs(from pasteboard: NSPasteboard) -> [URL] { + let objects = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) ?? [] + return objects.compactMap { object -> URL? in + if let url = object as? URL { + return url.isFileURL ? url : nil + } + if let url = object as? NSURL { + let bridged = url as URL + return bridged.isFileURL ? bridged : nil + } + return nil + } + } + + static func hasReadableFileURLs(from pasteboard: NSPasteboard = NSPasteboard(name: .drag)) -> Bool { + !fileURLs(from: pasteboard).isEmpty + } + private func decodeTransfer(from string: String) -> TabTransferData? { guard let data = string.data(using: .utf8), let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) else { diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 430f6517..3f842b4f 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -65,6 +65,25 @@ private struct TabFramePreferenceKey: PreferenceKey { } } +private struct SplitButtonLaneWidthPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct SplitButtonLaneWidthReader: View { + var body: some View { + GeometryReader { geometry in + Color.clear.preference( + key: SplitButtonLaneWidthPreferenceKey.self, + value: geometry.size.width + ) + } + } +} + @MainActor private final class TabBarScrollViewBridge: ObservableObject { private struct ScrollMetrics { @@ -174,6 +193,8 @@ private final class TabBarScrollViewBridge: ObservableObject { } enum TabBarStyling { + static let maximumSplitButtonLaneWidthFraction: CGFloat = 0.25 + static let splitButtonScrollFadeWidth: CGFloat = 12 static let splitActionButtonReservedWidth: CGFloat = 22 static let splitButtonsSpacing: CGFloat = 4 static let splitButtonsLeadingPadding: CGFloat = 6 @@ -191,6 +212,35 @@ enum TabBarStyling { + (CGFloat(max(0, buttonCount - 1)) * splitButtonsSpacing) } + static func splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: CGFloat, + visibleLaneWidth: CGFloat, + solidSurfaceWidthAdjustment: CGFloat + ) -> CGFloat { + let adjustedLaneWidth = max(0, visibleLaneWidth + solidSurfaceWidthAdjustment) + return max(max(0, effectSolidWidth), adjustedLaneWidth) + } + + static func splitButtonContentOcclusionWidth( + visibleLaneWidth: CGFloat, + contentOcclusionFraction: CGFloat + ) -> CGFloat { + max(0, visibleLaneWidth) * min(max(0, contentOcclusionFraction), 1) + } + + static func splitButtonScrollAffordances( + scrollOffset: CGFloat, + contentWidth: CGFloat, + viewportWidth: CGFloat + ) -> (left: Bool, right: Bool) { + let overflowThreshold: CGFloat = 1 + let maxOffset = max(0, contentWidth - viewportWidth) + return ( + left: scrollOffset > overflowThreshold, + right: scrollOffset < maxOffset - overflowThreshold + ) + } + static func imageDataShouldRenderAsTemplate(_ data: Data) -> Bool { let text = String(decoding: data.prefix(4096), as: UTF8.self) let lowercased = text.lowercased() @@ -276,6 +326,399 @@ enum TabBarStyling { } } +struct TabBarLayout: Equatable { + let barHeight: CGFloat + let availableWidth: CGFloat + let splitButtonCount: Int + let splitButtonLaneVisible: Bool + let reservesSplitButtonLane: Bool + let measuredSplitButtonLaneWidth: CGFloat + + init( + tabBarHeight: CGFloat, + availableWidth: CGFloat = 0, + splitButtonCount: Int, + splitButtonLaneVisible: Bool, + reservesSplitButtonLane: Bool, + measuredSplitButtonLaneWidth: CGFloat = 0 + ) { + self.barHeight = max(1, tabBarHeight) + self.availableWidth = max(0, availableWidth) + self.splitButtonCount = max(0, splitButtonCount) + self.splitButtonLaneVisible = splitButtonLaneVisible + self.reservesSplitButtonLane = reservesSplitButtonLane + self.measuredSplitButtonLaneWidth = self.splitButtonCount > 0 + ? max(0, measuredSplitButtonLaneWidth) + : 0 + } + + var minimumSplitButtonLaneWidth: CGFloat { + TabBarStyling.splitButtonsBackdropWidth(buttonCount: splitButtonCount) + } + + var fullSplitButtonLaneWidth: CGFloat { + max(minimumSplitButtonLaneWidth, measuredSplitButtonLaneWidth) + } + + var maximumSplitButtonLaneWidth: CGFloat { + guard availableWidth > 0 else { return fullSplitButtonLaneWidth } + return availableWidth * TabBarStyling.maximumSplitButtonLaneWidthFraction + } + + var visibleSplitButtonLaneWidth: CGFloat { + min(fullSplitButtonLaneWidth, maximumSplitButtonLaneWidth) + } + + var splitButtonLaneOverflowsViewport: Bool { + fullSplitButtonLaneWidth > visibleSplitButtonLaneWidth + 1 + } + + var trailingTabContentInset: CGFloat { + reservesSplitButtonLane ? visibleSplitButtonLaneWidth : 0 + } + + var splitActionButtonHeight: CGFloat { + barHeight + } + + func selectedSeparatorGap( + selectedTabFrame: CGRect?, + totalWidth: CGFloat + ) -> ClosedRange? { + guard let selectedTabFrame, totalWidth > 0 else { return nil } + + let minX = min(max(selectedTabFrame.minX, 0), totalWidth) + let maxX = min(max(selectedTabFrame.maxX, 0), totalWidth) + guard maxX > minX else { return nil } + return minX...maxX + } + + func selectedIndicatorFrame( + selectedTabFrame: CGRect?, + totalWidth: CGFloat + ) -> CGRect? { + guard let gap = selectedSeparatorGap( + selectedTabFrame: selectedTabFrame, + totalWidth: totalWidth + ) else { return nil } + + let minX = gap.lowerBound + let maxX = gap.upperBound + let width = max(0, maxX - minX - TabBarMetrics.activeIndicatorTrailingInset) + guard width > 0 else { return nil } + + return CGRect( + x: minX, + y: 0, + width: width, + height: TabBarMetrics.activeIndicatorHeight + ) + } +} + +struct TabBarActionLaneGeometry: Equatable { + let buttonViewportWidth: CGFloat + let contentFadeWidth: CGFloat + let contentOcclusionWidth: CGFloat + let backgroundFadeWidth: CGFloat + let backgroundSolidWidth: CGFloat + let separatorFadeWidth: CGFloat + let backgroundFadeRampStartFraction: CGFloat + + init( + layout: TabBarLayout, + effect: BonsplitConfiguration.Appearance.SplitButtonBackdropEffect, + masksTabContent: Bool + ) { + self.buttonViewportWidth = layout.visibleSplitButtonLaneWidth + self.contentFadeWidth = masksTabContent ? effect.contentFadeWidth : 0 + if masksTabContent { + let fractionalOcclusionWidth = TabBarStyling.splitButtonContentOcclusionWidth( + visibleLaneWidth: layout.visibleSplitButtonLaneWidth, + contentOcclusionFraction: effect.contentOcclusionFraction + ) + self.contentOcclusionWidth = layout.splitButtonLaneOverflowsViewport + ? layout.visibleSplitButtonLaneWidth + : fractionalOcclusionWidth + } else { + self.contentOcclusionWidth = 0 + } + self.backgroundFadeWidth = max(0, effect.fadeWidth) + let solidSurfaceWidthAdjustment = layout.splitButtonLaneOverflowsViewport + ? max(0, effect.solidSurfaceWidthAdjustment) + : effect.solidSurfaceWidthAdjustment + self.backgroundSolidWidth = TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: effect.solidWidth, + visibleLaneWidth: layout.visibleSplitButtonLaneWidth, + solidSurfaceWidthAdjustment: solidSurfaceWidthAdjustment + ) + let rampStart = min(max(0, effect.fadeRampStartFraction), 0.95) + self.backgroundFadeRampStartFraction = rampStart + let defaultSeparatorFadeWidth = self.backgroundFadeWidth + self.separatorFadeWidth = min( + defaultSeparatorFadeWidth, + effect.separatorFadeWidth ?? defaultSeparatorFadeWidth + ) + } + + var separatorTotalWidth: CGFloat { + separatorFadeWidth + backgroundSolidWidth + } + + func backgroundFadeFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let width = max(0, backgroundFadeWidth) + return CGRect( + x: totalWidth - backgroundSolidWidth - width, + y: 0, + width: width, + height: height + ) + } + + func backgroundSolidFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let width = max(0, backgroundSolidWidth) + return CGRect( + x: totalWidth - width, + y: 0, + width: width, + height: height + ) + } + + func separatorFadeFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let width = max(0, separatorFadeWidth) + return CGRect( + x: totalWidth - backgroundSolidWidth - width, + y: height - 1, + width: width, + height: 1 + ) + } + + func separatorSolidFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let solid = backgroundSolidFrame(totalWidth: totalWidth, height: height) + return CGRect(x: solid.minX, y: height - 1, width: solid.width, height: 1) + } + + func separatorCoverageFrame(totalWidth: CGFloat, height: CGFloat) -> CGRect { + let width = separatorTotalWidth + return CGRect(x: totalWidth - width, y: height - 1, width: width, height: 1) + } + + func fallbackSeparatorMaskFrame( + totalWidth: CGFloat, + height: CGFloat, + selectedSeparatorGap: ClosedRange? + ) -> CGRect? { + guard let selectedSeparatorGap else { return nil } + let coverage = separatorCoverageFrame(totalWidth: totalWidth, height: height) + let start = max(coverage.minX, selectedSeparatorGap.lowerBound) + let end = min(coverage.maxX, selectedSeparatorGap.upperBound) + guard end > start else { return nil } + return CGRect(x: start, y: height - 1, width: end - start, height: 1) + } +} + +struct TabBarChromeSnapshot { + let layout: TabBarLayout + let actionLaneGeometry: TabBarActionLaneGeometry + let barColor: NSColor + let actionLaneWidth: CGFloat + let paintsActionLaneSurface: Bool + let masksTabContentUnderActionLane: Bool + let contentFadeWidth: CGFloat + let contentOcclusionWidth: CGFloat + let actionLaneSeparatorFadeWidth: CGFloat + let backdropFadeWidth: CGFloat + let backdropSolidWidth: CGFloat + let backdropFadeRampStartFraction: CGFloat + let backdropLeadingColor: NSColor + let backdropTrailingColor: NSColor + + var drawsActionLaneSeparator: Bool { + paintsActionLaneSurface || masksTabContentUnderActionLane + } + + var backdropVisibleFadeWidth: CGFloat { + backdropFadeWidth * (1 - backdropFadeRampStartFraction) + } + + var actionLaneSeparatorSolidWidth: CGFloat { + actionLaneGeometry.backgroundSolidWidth + } + + init( + appearance: BonsplitConfiguration.Appearance, + layout: TabBarLayout, + isFocused: Bool, + shouldShowSplitButtons: Bool, + fadeColorStyle: Int + ) { + self.layout = layout + + let baseBarColor = TabBarColors.nsColorBarBackground(for: appearance) + self.barColor = appearance.usesSharedBackdrop || isFocused + ? baseBarColor + : baseBarColor.withAlphaComponent(baseBarColor.alphaComponent * 0.95) + + let effect = Self.splitButtonBackdropEffect( + for: appearance, + fadeColorStyle: fadeColorStyle + ) + let targetColor = Self.buttonBackdropColor( + for: appearance, + focused: isFocused, + style: effect.style + ) + let colors = Self.splitButtonBackdropColors( + from: barColor, + to: targetColor, + leadingOpacity: effect.leadingOpacity, + trailingOpacity: effect.trailingOpacity, + usesSharedBackdrop: appearance.usesSharedBackdrop + ) + + let canUseActionLaneChrome = shouldShowSplitButtons && effect.style != .hidden + self.paintsActionLaneSurface = canUseActionLaneChrome + && TabBarColors.shouldPaintSplitButtonBackdrop(for: appearance) + self.masksTabContentUnderActionLane = canUseActionLaneChrome && effect.masksTabContent + let geometry = TabBarActionLaneGeometry( + layout: layout, + effect: effect, + masksTabContent: masksTabContentUnderActionLane + ) + self.actionLaneGeometry = geometry + self.actionLaneWidth = geometry.buttonViewportWidth + self.contentFadeWidth = geometry.contentFadeWidth + self.contentOcclusionWidth = geometry.contentOcclusionWidth + self.backdropFadeWidth = geometry.backgroundFadeWidth + self.backdropSolidWidth = geometry.backgroundSolidWidth + self.backdropFadeRampStartFraction = min(max(0, effect.fadeRampStartFraction), 0.95) + self.actionLaneSeparatorFadeWidth = geometry.separatorFadeWidth + self.backdropLeadingColor = colors.leading + self.backdropTrailingColor = colors.trailing + } + + private static func splitButtonBackdropEffect( + for appearance: BonsplitConfiguration.Appearance, + fadeColorStyle: Int + ) -> BonsplitConfiguration.Appearance.SplitButtonBackdropEffect { + if let effect = appearance.splitButtonBackdropEffect { + return effect + } + if let style = appearance.splitButtonBackdropStyle { + return .init(style: style) + } + if let debugStyle = BonsplitConfiguration.Appearance.SplitButtonBackdropStyle(rawValue: fadeColorStyle) { + return .init( + style: debugStyle, + fadeWidth: 136, + solidWidth: 2, + fadeRampStartFraction: 0.80, + leadingOpacity: 0, + trailingOpacity: 0.80, + masksTabContent: false + ) + } + return .default + } + + private static func buttonBackdropColor( + for appearance: BonsplitConfiguration.Appearance, + focused: Bool, + style: BonsplitConfiguration.Appearance.SplitButtonBackdropStyle + ) -> NSColor { + if appearance.usesSharedBackdrop { + return TabBarColors.nsColorSplitButtonBackdropOccludingSurface(for: appearance) + } + + switch style { + case .opaquePaneBackground: + return TabBarColors.nsColorPaneBackground(for: appearance).withAlphaComponent(1.0) + case .opaqueBarBackground: + return TabBarColors.nsColorBarBackground(for: appearance).withAlphaComponent(1.0) + case .windowBackground: + return NSColor.windowBackgroundColor.withAlphaComponent(1.0) + case .controlBackground: + return NSColor.controlBackgroundColor.withAlphaComponent(1.0) + case .precompositedBarBackground: + let chrome = TabBarColors.nsColorBarBackground(for: appearance) + let winBg = NSColor.windowBackgroundColor + guard let fg = chrome.usingColorSpace(.sRGB), + let bk = winBg.usingColorSpace(.sRGB) else { + return chrome.withAlphaComponent(1.0) + } + let a: CGFloat = focused ? fg.alphaComponent : fg.alphaComponent * 0.95 + let oneMinusA = 1.0 - a + let r = fg.redComponent * a + bk.redComponent * oneMinusA + let g = fg.greenComponent * a + bk.greenComponent * oneMinusA + let b = fg.blueComponent * a + bk.blueComponent * oneMinusA + return NSColor(red: r, green: g, blue: b, alpha: 1.0) + case .translucentChrome: + let backdrop = TabBarColors.nsColorSplitButtonBackdropSurface(for: appearance) + let alpha = focused ? backdrop.alphaComponent : backdrop.alphaComponent * 0.95 + return backdrop.withAlphaComponent(alpha) + case .hidden: + return .clear + case .precompositedPaneBackground: + return TabBarColors.nsColorSplitButtonBackdrop(for: appearance, focused: focused) + } + } + + private static func blendedSurfaceColor( + from base: NSColor, + to target: NSColor, + amount: CGFloat + ) -> NSColor { + let clampedAmount = min(max(amount, 0), 1) + let source = base.usingColorSpace(.sRGB) ?? base + let destination = target.usingColorSpace(.sRGB) ?? target + let inverse = 1 - clampedAmount + return NSColor( + red: source.redComponent * inverse + destination.redComponent * clampedAmount, + green: source.greenComponent * inverse + destination.greenComponent * clampedAmount, + blue: source.blueComponent * inverse + destination.blueComponent * clampedAmount, + alpha: source.alphaComponent * inverse + destination.alphaComponent * clampedAmount + ) + } + + private static func splitButtonBackdropColors( + from base: NSColor, + to target: NSColor, + leadingOpacity: CGFloat, + trailingOpacity: CGFloat, + usesSharedBackdrop: Bool + ) -> (leading: NSColor, trailing: NSColor) { + if usesSharedBackdrop { + return ( + alphaOnlySurfaceColor(target, opacity: leadingOpacity), + alphaOnlySurfaceColor(target, opacity: trailingOpacity) + ) + } + + return ( + blendedSurfaceColor(from: base, to: target, amount: leadingOpacity), + blendedSurfaceColor(from: base, to: target, amount: trailingOpacity) + ) + } + + private static func alphaOnlySurfaceColor( + _ color: NSColor, + opacity: CGFloat + ) -> NSColor { + let clampedOpacity = min(max(opacity, 0), 1) + guard let source = color.usingColorSpace(.sRGB) else { + return color.withAlphaComponent(color.alphaComponent * clampedOpacity) + } + return NSColor( + red: source.redComponent, + green: source.greenComponent, + blue: source.blueComponent, + alpha: source.alphaComponent * clampedOpacity + ) + } +} + struct TabContextMenuState { let isPinned: Bool let isUnread: Bool @@ -321,6 +764,10 @@ struct TabBarView: View { @State private var containerWidth: CGFloat = 0 @State private var selectedTabFrameInBar: CGRect? @State private var tabFramesInBar: [UUID: CGRect] = [:] + @State private var measuredSplitButtonLaneWidth: CGFloat = 0 + @State private var splitButtonScrollOffset: CGFloat = 0 + @State private var splitButtonContentWidth: CGFloat = 0 + @State private var splitButtonViewportWidth: CGFloat = 0 @StateObject private var controlKeyMonitor = TabControlShortcutKeyMonitor() @StateObject private var scrollViewBridge = TabBarScrollViewBridge() @@ -349,7 +796,28 @@ struct TabBarView: View { } private var tabBarHeight: CGFloat { - max(1, appearance.tabBarHeight) + tabBarLayout.barHeight + } + + private var tabBarLayout: TabBarLayout { + TabBarLayout( + tabBarHeight: appearance.tabBarHeight, + availableWidth: containerWidth, + splitButtonCount: visibleSplitButtons.count, + splitButtonLaneVisible: shouldShowSplitButtons, + reservesSplitButtonLane: showSplitButtons && !isMinimalMode, + measuredSplitButtonLaneWidth: measuredSplitButtonLaneWidth + ) + } + + private var chromeSnapshot: TabBarChromeSnapshot { + TabBarChromeSnapshot( + appearance: appearance, + layout: tabBarLayout, + isFocused: isFocused, + shouldShowSplitButtons: shouldShowSplitButtons, + fadeColorStyle: fadeColorStyle + ) } private var visibleSplitButtons: [BonsplitConfiguration.SplitActionButton] { @@ -365,59 +833,8 @@ struct TabBarView: View { shouldRenderSplitButtons && (!isMinimalMode || isHoveringTabBar) } - private var splitButtonBackdropEffect: BonsplitConfiguration.Appearance.SplitButtonBackdropEffect { - if let effect = appearance.splitButtonBackdropEffect { - return effect - } - if let style = appearance.splitButtonBackdropStyle { - return .init(style: style) - } - if let debugStyle = BonsplitConfiguration.Appearance.SplitButtonBackdropStyle(rawValue: fadeColorStyle) { - return .init( - style: debugStyle, - fadeWidth: 136, - solidWidth: 2, - fadeRampStartFraction: 0.80, - leadingOpacity: 0, - trailingOpacity: 0.80, - masksTabContent: false - ) - } - return .default - } - - private var shouldPaintSplitButtonBackdrop: Bool { - shouldShowSplitButtons - && splitButtonBackdropEffect.style != .hidden - && TabBarColors.shouldPaintSplitButtonBackdrop(for: appearance) - } - - private var shouldMaskTabsUnderSplitButtonBackdrop: Bool { - shouldPaintSplitButtonBackdrop && splitButtonBackdropEffect.masksTabContent - } - private var splitButtonsBackdropWidth: CGFloat { - TabBarStyling.splitButtonsBackdropWidth(buttonCount: visibleSplitButtons.count) - } - - private var splitButtonBackdropFadeWidth: CGFloat { - max(0, splitButtonBackdropEffect.fadeWidth) - } - - private var splitButtonBackdropSolidWidth: CGFloat { - max(0, splitButtonBackdropEffect.solidWidth) - } - - private var splitButtonBackdropFadeRampStartFraction: CGFloat { - min(max(0, splitButtonBackdropEffect.fadeRampStartFraction), 0.95) - } - - private var splitButtonContentFadeWidth: CGFloat { - max(0, splitButtonBackdropEffect.contentFadeWidth) - } - - private var splitButtonContentOcclusionWidth: CGFloat { - splitButtonsBackdropWidth * min(max(0, splitButtonBackdropEffect.contentOcclusionFraction), 1) + chromeSnapshot.actionLaneWidth } private var showsControlShortcutHints: Bool { @@ -429,17 +846,25 @@ struct TabBarView: View { } private var trailingTabContentInset: CGFloat { - TabBarStyling.trailingTabContentInset( - showSplitButtons: showSplitButtons, - isMinimalMode: isMinimalMode, - buttonCount: visibleSplitButtons.count - ) + tabBarLayout.trailingTabContentInset } private var leadingScrollAnchorId: String { "tab-bar-leading-\(pane.id.id.uuidString)" } + private var splitButtonScrollCoordinateSpaceName: String { + "split-button-scroll-\(pane.id.id.uuidString)" + } + + private var splitButtonScrollAffordances: (left: Bool, right: Bool) { + TabBarStyling.splitButtonScrollAffordances( + scrollOffset: splitButtonScrollOffset, + contentWidth: splitButtonContentWidth, + viewportWidth: splitButtonViewportWidth + ) + } + private func focusPaneFromTabBarChrome() -> Bool { guard !isFocused else { return false } withTransaction(Transaction(animation: nil)) { @@ -583,26 +1008,29 @@ struct TabBarView: View { } .frame(height: tabBarHeight) .mask(combinedMask) - // Split buttons sit on top of the tab strip. Their backing surface is - // painted by `tabBarBackground` so translucent colors are composited once, - // while `combinedMask` fades overflowing tab content out below them. - .overlay(alignment: .trailing) { - if shouldRenderSplitButtons { - splitButtons - .saturation(tabBarSaturation) - .padding(.bottom, 1) - .frame(width: splitButtonsBackdropWidth, alignment: .trailing) - .opacity(shouldShowSplitButtons ? 1 : 0) - .allowsHitTesting(shouldShowSplitButtons) - .animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons) - } - } } } .frame(maxWidth: .infinity, alignment: .leading) .frame(height: tabBarHeight) .coordinateSpace(name: "tabBar") - .background(tabBarBackground) + .background(tabBarSurface) + .overlay(maskedSelectedTabIndicatorChrome) + .overlay(alignment: .trailing) { + splitButtonBackdropChrome + .opacity(shouldShowSplitButtons ? 1 : 0) + .allowsHitTesting(false) + .animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons) + } + .overlay(maskedTabBarBottomSeparatorChrome) + .overlay(alignment: .trailing) { + splitButtonChrome + .frame(width: splitButtonsBackdropWidth, height: tabBarHeight, alignment: .trailing) + .mask { + Rectangle() + .frame(width: splitButtonsBackdropWidth, height: tabBarHeight) + } + .clipped() + } .background(TabBarDragAndHoverView( isMinimalMode: isMinimalMode, onDoubleClick: { @@ -652,6 +1080,9 @@ struct TabBarView: View { .onPreferenceChange(TabFramePreferenceKey.self) { frames in tabFramesInBar = frames } + .onPreferenceChange(SplitButtonLaneWidthPreferenceKey.self) { width in + measuredSplitButtonLaneWidth = width + } .onDisappear { controlKeyMonitor.stop() } @@ -908,8 +1339,191 @@ struct TabBarView: View { // MARK: - Split Buttons + @ViewBuilder + private var splitButtonChrome: some View { + if shouldRenderSplitButtons { + splitButtons + .saturation(tabBarSaturation) + .opacity(shouldShowSplitButtons ? 1 : 0) + .allowsHitTesting(shouldShowSplitButtons) + .frame(height: tabBarHeight, alignment: .trailing) + .animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons) + } + } + + @ViewBuilder + private var splitButtonBackdropChrome: some View { + let snapshot = chromeSnapshot + if snapshot.drawsActionLaneSeparator { + GeometryReader { geometry in + ZStack(alignment: .topLeading) { + if snapshot.paintsActionLaneSurface { + splitButtonBackdropSurface(snapshot: snapshot, totalWidth: geometry.size.width) + } + + splitButtonFallbackSeparator(snapshot: snapshot, totalWidth: geometry.size.width) + } + .frame(width: geometry.size.width, height: tabBarHeight, alignment: .topLeading) + } + .frame(height: tabBarHeight) + } + } + + @ViewBuilder + private func splitButtonBackdropSurface(snapshot: TabBarChromeSnapshot, totalWidth: CGFloat) -> some View { + let geometry = snapshot.actionLaneGeometry + let fadeFrame = geometry.backgroundFadeFrame(totalWidth: totalWidth, height: tabBarHeight) + let solidFrame = geometry.backgroundSolidFrame(totalWidth: totalWidth, height: tabBarHeight) + ZStack(alignment: .topLeading) { + if fadeFrame.width > 0 { + splitButtonBackdropFadeSegment(snapshot: snapshot) + .frame(width: fadeFrame.width, height: fadeFrame.height) + .offset(x: fadeFrame.minX, y: fadeFrame.minY) + } + if solidFrame.width > 0 { + TabBarLayerBackedColor(color: snapshot.backdropTrailingColor) + .frame(width: solidFrame.width, height: solidFrame.height) + .offset(x: solidFrame.minX, y: solidFrame.minY) + } + } + .frame(width: totalWidth, height: tabBarHeight, alignment: .topLeading) + } + + @ViewBuilder + private func splitButtonBackdropFadeSegment(snapshot: TabBarChromeSnapshot) -> some View { + let rampStart = snapshot.backdropFadeRampStartFraction + LinearGradient( + stops: [ + .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: 0), + .init(color: Color(nsColor: snapshot.backdropLeadingColor), location: rampStart), + .init(color: Color(nsColor: snapshot.backdropTrailingColor), location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + @ViewBuilder + private func splitButtonFallbackSeparator(snapshot: TabBarChromeSnapshot, totalWidth: CGFloat) -> some View { + let geometry = snapshot.actionLaneGeometry + let selectedGap = tabBarLayout.selectedSeparatorGap( + selectedTabFrame: selectedTabFrameInBar, + totalWidth: totalWidth + ) + if let maskFrame = geometry.fallbackSeparatorMaskFrame( + totalWidth: totalWidth, + height: tabBarHeight, + selectedSeparatorGap: selectedGap + ) { + ZStack(alignment: .topLeading) { + let fadeFrame = geometry.separatorFadeFrame(totalWidth: totalWidth, height: tabBarHeight) + if fadeFrame.width > 0 { + splitButtonSeparatorFadeSegment(snapshot: snapshot) + .frame(width: fadeFrame.width, height: fadeFrame.height) + .offset(x: fadeFrame.minX, y: fadeFrame.minY) + } + let solidFrame = geometry.separatorSolidFrame(totalWidth: totalWidth, height: tabBarHeight) + if solidFrame.width > 0 { + Rectangle() + .fill(TabBarColors.separator(for: appearance)) + .frame(width: solidFrame.width, height: solidFrame.height) + .offset(x: solidFrame.minX, y: solidFrame.minY) + } + } + .frame(width: totalWidth, height: tabBarHeight, alignment: .topLeading) + .mask(alignment: .topLeading) { + Rectangle() + .frame(width: maskFrame.width, height: maskFrame.height) + .offset(x: maskFrame.minX, y: maskFrame.minY) + } + } + } + + @ViewBuilder + private func splitButtonSeparatorFadeSegment(snapshot: TabBarChromeSnapshot) -> some View { + let separator = TabBarColors.separator(for: appearance) + let rampStart = snapshot.backdropFadeRampStartFraction + LinearGradient( + stops: [ + .init(color: separator.opacity(0), location: 0), + .init(color: separator.opacity(0), location: rampStart), + .init(color: separator, location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + @ViewBuilder private var splitButtons: some View { + let laneWidth = splitButtonsBackdropWidth + ZStack(alignment: .trailing) { + ScrollView(.horizontal, showsIndicators: false) { + splitButtonRow + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: laneWidth, alignment: .trailing) + .background(SplitButtonLaneWidthReader()) + .background( + GeometryReader { contentGeo in + Color.clear + .onChange( + of: contentGeo.frame(in: .named(splitButtonScrollCoordinateSpaceName)) + ) { _, newFrame in + updateSplitButtonScrollContent(frame: newFrame) + } + .onAppear { + updateSplitButtonScrollContent( + frame: contentGeo.frame(in: .named(splitButtonScrollCoordinateSpaceName)) + ) + } + } + ) + } + .coordinateSpace(name: splitButtonScrollCoordinateSpaceName) + .frame(width: laneWidth, height: tabBarHeight, alignment: .trailing) + .background( + GeometryReader { viewportGeo in + Color.clear + .onChange(of: viewportGeo.size.width) { _, newWidth in + splitButtonViewportWidth = newWidth + } + .onAppear { + splitButtonViewportWidth = viewportGeo.size.width + } + } + ) + .mask( + splitButtonScrollMask + .frame(width: laneWidth, height: tabBarHeight) + ) + } + .frame(width: laneWidth, height: tabBarHeight, alignment: .trailing) + .contentShape(Rectangle()) + .clipped() + } + + @ViewBuilder + private var splitButtonScrollMask: some View { + let affordances = splitButtonScrollAffordances + let fadeWidth = TabBarStyling.splitButtonScrollFadeWidth + HStack(spacing: 0) { + LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) + .frame(width: affordances.left ? fadeWidth : 0, height: tabBarHeight) + Rectangle().fill(Color.black) + .frame(height: tabBarHeight) + LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) + .frame(width: affordances.right ? fadeWidth : 0, height: tabBarHeight) + } + .frame(height: tabBarHeight) + } + + private func updateSplitButtonScrollContent(frame: CGRect) { + splitButtonScrollOffset = max(0, -frame.minX) + splitButtonContentWidth = frame.width + } + + @ViewBuilder + private var splitButtonRow: some View { let tooltips = controller.configuration.appearance.splitButtonTooltips let buttons = visibleSplitButtons HStack(spacing: TabBarStyling.splitButtonsSpacing) { @@ -920,13 +1534,14 @@ struct TabBarView: View { } label: { splitActionButtonIcon(button.icon) } - .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .buttonStyle(SplitActionButtonStyle(appearance: appearance, layout: tabBarLayout)) .accessibilityIdentifier(splitActionButtonAccessibilityIdentifier(button)) .safeHelp(splitActionButtonTooltip(button, tooltips: tooltips)) } } .padding(.leading, TabBarStyling.splitButtonsLeadingPadding) .padding(.trailing, TabBarStyling.splitButtonsTrailingPadding) + .frame(height: tabBarHeight, alignment: .center) } private func splitActionButtonAccessibilityIdentifier(_ button: BonsplitConfiguration.SplitActionButton) -> String { @@ -1036,101 +1651,6 @@ struct TabBarView: View { } - private static func buttonBackdropColor( - for appearance: BonsplitConfiguration.Appearance, - focused: Bool, - style: BonsplitConfiguration.Appearance.SplitButtonBackdropStyle - ) -> NSColor { - if appearance.usesSharedBackdrop { - return TabBarColors.nsColorSplitButtonBackdropSurface(for: appearance) - } - - switch style { - case .opaquePaneBackground: - return TabBarColors.nsColorPaneBackground(for: appearance).withAlphaComponent(1.0) - case .opaqueBarBackground: - return TabBarColors.nsColorBarBackground(for: appearance).withAlphaComponent(1.0) - case .windowBackground: - return NSColor.windowBackgroundColor.withAlphaComponent(1.0) - case .controlBackground: - return NSColor.controlBackgroundColor.withAlphaComponent(1.0) - case .precompositedBarBackground: - let chrome = TabBarColors.nsColorBarBackground(for: appearance) - let winBg = NSColor.windowBackgroundColor - guard let fg = chrome.usingColorSpace(.sRGB), - let bk = winBg.usingColorSpace(.sRGB) else { - return chrome.withAlphaComponent(1.0) - } - let a: CGFloat = focused ? fg.alphaComponent : fg.alphaComponent * 0.95 - let oneMinusA = 1.0 - a - let r = fg.redComponent * a + bk.redComponent * oneMinusA - let g = fg.greenComponent * a + bk.greenComponent * oneMinusA - let b = fg.blueComponent * a + bk.blueComponent * oneMinusA - return NSColor(red: r, green: g, blue: b, alpha: 1.0) - case .translucentChrome: - let backdrop = TabBarColors.nsColorSplitButtonBackdropSurface(for: appearance) - let alpha = focused ? backdrop.alphaComponent : backdrop.alphaComponent * 0.95 - return backdrop.withAlphaComponent(alpha) - case .hidden: - return .clear - case .precompositedPaneBackground: - return TabBarColors.nsColorSplitButtonBackdrop(for: appearance, focused: focused) - } - } - - private static func blendedSurfaceColor( - from base: NSColor, - to target: NSColor, - amount: CGFloat - ) -> NSColor { - let clampedAmount = min(max(amount, 0), 1) - let source = base.usingColorSpace(.sRGB) ?? base - let destination = target.usingColorSpace(.sRGB) ?? target - let inverse = 1 - clampedAmount - return NSColor( - red: source.redComponent * inverse + destination.redComponent * clampedAmount, - green: source.greenComponent * inverse + destination.greenComponent * clampedAmount, - blue: source.blueComponent * inverse + destination.blueComponent * clampedAmount, - alpha: source.alphaComponent * inverse + destination.alphaComponent * clampedAmount - ) - } - - private static func splitButtonBackdropColors( - from base: NSColor, - to target: NSColor, - leadingOpacity: CGFloat, - trailingOpacity: CGFloat, - usesSharedBackdrop: Bool - ) -> (leading: NSColor, trailing: NSColor) { - if usesSharedBackdrop { - return ( - alphaOnlySurfaceColor(target, opacity: leadingOpacity), - alphaOnlySurfaceColor(target, opacity: trailingOpacity) - ) - } - - return ( - blendedSurfaceColor(from: base, to: target, amount: leadingOpacity), - blendedSurfaceColor(from: base, to: target, amount: trailingOpacity) - ) - } - - private static func alphaOnlySurfaceColor( - _ color: NSColor, - opacity: CGFloat - ) -> NSColor { - let clampedOpacity = min(max(opacity, 0), 1) - guard let source = color.usingColorSpace(.sRGB) else { - return color.withAlphaComponent(color.alphaComponent * clampedOpacity) - } - return NSColor( - red: source.redComponent, - green: source.greenComponent, - blue: source.blueComponent, - alpha: source.alphaComponent * clampedOpacity - ) - } - // MARK: - Combined Mask (scroll fades + button area) // // The split-button backdrop is responsible for occluding content under the controls. @@ -1140,101 +1660,57 @@ struct TabBarView: View { @ViewBuilder private var combinedMask: some View { let fadeWidth: CGFloat = 24 + let snapshot = chromeSnapshot HStack(spacing: 0) { // Left scroll fade LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollLeft ? fadeWidth : 0) + .frame(width: canScrollLeft ? fadeWidth : 0, height: tabBarHeight) // Visible content area (always opaque so hit testing reaches the tabs) Rectangle().fill(Color.black) + .frame(height: tabBarHeight) - if shouldMaskTabsUnderSplitButtonBackdrop { + if snapshot.masksTabContentUnderActionLane { LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) - .frame(width: splitButtonContentFadeWidth) - // Content is already fully faded before the backdrop ramp starts. This keeps the - // beginning of a transparent backdrop fade from blending over bright tab text. + .frame(width: snapshot.contentFadeWidth, height: tabBarHeight) + // Action-lane content is always fully removed from the tab layer. + // Backdrop softness belongs to the foreground chrome snapshot, not the mask width. Color.clear - .frame(width: splitButtonContentOcclusionWidth) + .frame(width: snapshot.contentOcclusionWidth, height: tabBarHeight) } else { // Right scroll fade only when scroll content actually overflows. LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollRight ? fadeWidth : 0) + .frame(width: canScrollRight ? fadeWidth : 0, height: tabBarHeight) } } + .frame(height: tabBarHeight) } - // MARK: - Fade Overlays + // MARK: - Chrome - /// Mask that fades scroll content at the edges instead of overlaying - /// a colored gradient. The mask uses black (visible) → clear (hidden), - /// so the tab bar background shows through naturally with no compositing. @ViewBuilder - private var fadeOverlays: some View { - let fadeWidth: CGFloat = 24 - HStack(spacing: 0) { - LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollLeft ? fadeWidth : 0) - - Rectangle().fill(Color.black) + private var tabBarSurface: some View { + TabBarLayerBackedColor(color: chromeSnapshot.barColor) + .frame(maxWidth: .infinity) + .frame(height: tabBarHeight) + } - LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) - .frame(width: canScrollRight ? fadeWidth : 0) + @ViewBuilder + private var maskedSelectedTabIndicatorChrome: some View { + GeometryReader { geometry in + selectedTabIndicator(totalWidth: geometry.size.width) + .frame(width: geometry.size.width, height: tabBarHeight, alignment: .topLeading) + .mask(combinedMask) } + .allowsHitTesting(false) } - // MARK: - Background - @ViewBuilder - private var tabBarBackground: some View { - let baseBarColor = TabBarColors.nsColorBarBackground(for: appearance) - let barColor = appearance.usesSharedBackdrop || isFocused - ? baseBarColor - : baseBarColor.withAlphaComponent(baseBarColor.alphaComponent * 0.95) - HStack(spacing: 0) { - TabBarLayerBackedColor(color: barColor) - .frame(maxWidth: .infinity) - if shouldPaintSplitButtonBackdrop { - let effect = splitButtonBackdropEffect - let targetColor = Self.buttonBackdropColor( - for: appearance, - focused: isFocused, - style: effect.style - ) - let colors = Self.splitButtonBackdropColors( - from: barColor, - to: targetColor, - leadingOpacity: effect.leadingOpacity, - trailingOpacity: effect.trailingOpacity, - usesSharedBackdrop: appearance.usesSharedBackdrop - ) - if splitButtonBackdropFadeWidth > 0 { - let rampStart = splitButtonBackdropFadeRampStartFraction - LinearGradient( - stops: [ - .init(color: Color(nsColor: colors.leading), location: 0), - .init(color: Color(nsColor: colors.leading), location: rampStart), - .init(color: Color(nsColor: colors.trailing), location: 1) - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: splitButtonBackdropFadeWidth) - } - TabBarLayerBackedColor(color: colors.trailing) - .frame(width: splitButtonBackdropSolidWidth) - } - } - .overlay { - GeometryReader { geometry in - // Bar-edge chrome stays in tab-bar coordinates so item content - // offsets and padding cannot move it. - ZStack(alignment: .topLeading) { - selectedTabIndicator(totalWidth: geometry.size.width) - tabBarBottomSeparator(totalWidth: geometry.size.width) - } - } - .allowsHitTesting(false) + private var maskedTabBarBottomSeparatorChrome: some View { + GeometryReader { geometry in + tabBarBottomSeparator(totalWidth: geometry.size.width) } + .allowsHitTesting(false) } @ViewBuilder @@ -1253,9 +1729,10 @@ struct TabBarView: View { Spacer(minLength: 0) HStack(spacing: 0) { let separator = TabBarColors.separator(for: appearance) - let gapRange: ClosedRange? = selectedTabFrameInBar.map { frame in - frame.minX...frame.maxX - } + let gapRange = tabBarLayout.selectedSeparatorGap( + selectedTabFrame: selectedTabFrameInBar, + totalWidth: totalWidth + ) let segments = TabBarStyling.separatorSegments( totalWidth: totalWidth, gap: gapRange @@ -1272,16 +1749,9 @@ struct TabBarView: View { } private func selectedIndicatorFrame(totalWidth: CGFloat) -> CGRect? { - guard let selectedTabFrameInBar, totalWidth > 0 else { return nil } - let minX = min(max(selectedTabFrameInBar.minX, 0), totalWidth) - let maxX = min(max(selectedTabFrameInBar.maxX, 0), totalWidth) - let width = max(0, maxX - minX - TabBarMetrics.activeIndicatorTrailingInset) - guard width > 0 else { return nil } - return CGRect( - x: minX, - y: 0, - width: width, - height: TabBarMetrics.activeIndicatorHeight + tabBarLayout.selectedIndicatorFrame( + selectedTabFrame: selectedTabFrameInBar, + totalWidth: totalWidth ) } } @@ -1371,9 +1841,12 @@ private final class SplitActionButtonImageCache { private struct SplitActionButtonStyle: ButtonStyle { let appearance: BonsplitConfiguration.Appearance + let layout: TabBarLayout func makeBody(configuration: Configuration) -> some View { configuration.label + .frame(height: layout.splitActionButtonHeight) + .contentShape(Rectangle()) .foregroundStyle(TabBarColors.splitActionIcon(for: appearance, isPressed: configuration.isPressed)) .opacity(configuration.isPressed ? 0.72 : 1.0) .animation(.easeOut(duration: 0.08), value: configuration.isPressed) diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration.swift b/Sources/Bonsplit/Public/BonsplitConfiguration.swift index 5f875191..632ba937 100644 --- a/Sources/Bonsplit/Public/BonsplitConfiguration.swift +++ b/Sources/Bonsplit/Public/BonsplitConfiguration.swift @@ -351,6 +351,8 @@ extension BonsplitConfiguration { public var fadeWidth: CGFloat public var contentFadeWidth: CGFloat public var solidWidth: CGFloat + public var solidSurfaceWidthAdjustment: CGFloat + public var separatorFadeWidth: CGFloat? public var fadeRampStartFraction: CGFloat public var leadingOpacity: CGFloat public var trailingOpacity: CGFloat @@ -362,6 +364,8 @@ extension BonsplitConfiguration { fadeWidth: CGFloat = 136, contentFadeWidth: CGFloat = 42, solidWidth: CGFloat = 2, + solidSurfaceWidthAdjustment: CGFloat = 0, + separatorFadeWidth: CGFloat? = nil, fadeRampStartFraction: CGFloat = 0.80, leadingOpacity: CGFloat = 0, trailingOpacity: CGFloat = 0.80, @@ -372,6 +376,10 @@ extension BonsplitConfiguration { self.fadeWidth = max(0, fadeWidth) self.contentFadeWidth = max(0, contentFadeWidth) self.solidWidth = max(0, solidWidth) + self.solidSurfaceWidthAdjustment = solidSurfaceWidthAdjustment.isFinite + ? solidSurfaceWidthAdjustment + : 0 + self.separatorFadeWidth = separatorFadeWidth.map { max(0, $0) } self.fadeRampStartFraction = min(max(0, fadeRampStartFraction), 0.95) self.leadingOpacity = min(max(0, leadingOpacity), 1) self.trailingOpacity = min(max(0, trailingOpacity), 1) diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index f213df1a..c4d2d0f6 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -23,6 +23,16 @@ public final class BonsplitController { } } + public struct ExternalFileDropRequest { + public let urls: [URL] + public let destination: ExternalTabDropRequest.Destination + + public init(urls: [URL], destination: ExternalTabDropRequest.Destination) { + self.urls = urls + self.destination = destination + } + } + // MARK: - Delegate /// Delegate for receiving callbacks about tab bar events @@ -60,6 +70,10 @@ public final class BonsplitController { /// Return `true` when the drop has been handled by the host application. @ObservationIgnored public var onExternalTabDrop: ((ExternalTabDropRequest) -> Bool)? + /// Handler for file drops from external apps, routed through pane drop zones. + /// Return `true` when the drop has been handled by the host application. + @ObservationIgnored public var onExternalFileDrop: ((ExternalFileDropRequest) -> Bool)? + /// Host-provided destinations for the tab context menu's Move Tab submenu. @ObservationIgnored public var tabContextMoveDestinationsProvider: ((TabID, PaneID) -> [TabContextMoveDestination])? diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 7ef29bb7..50f2916c 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -372,6 +372,245 @@ final class BonsplitTests: XCTestCase { ) } + func testTabBarLayoutKeepsDefaultSplitButtonLaneWidthAsMinimum() { + let compactMeasuredWidth = + TabBarStyling.splitButtonsLeadingPadding + + TabBarStyling.splitButtonsTrailingPadding + + (4 * CGFloat(14)) + + (3 * TabBarStyling.splitButtonsSpacing) + let layout = TabBarLayout( + tabBarHeight: 28, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: compactMeasuredWidth + ) + + XCTAssertEqual( + layout.fullSplitButtonLaneWidth, + TabBarStyling.splitButtonsBackdropWidth(buttonCount: 4) + ) + } + + func testTabBarLayoutExpandsForMeasuredSplitButtonLaneWidth() { + let layout = TabBarLayout( + tabBarHeight: 28, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) + + XCTAssertEqual(layout.fullSplitButtonLaneWidth, 160) + XCTAssertEqual(layout.trailingTabContentInset, 160) + } + + func testTabBarLayoutCapsSplitButtonLaneToQuarterOfAvailableWidth() { + let layout = TabBarLayout( + tabBarHeight: 28, + availableWidth: 240, + splitButtonCount: 12, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 400 + ) + + XCTAssertEqual(layout.fullSplitButtonLaneWidth, 400) + XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 60) + XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 60) + XCTAssertEqual(layout.trailingTabContentInset, 60) + XCTAssertTrue(layout.splitButtonLaneOverflowsViewport) + } + + func testTabBarLayoutKeepsMeasuredLaneWhenItFitsQuarterOfAvailableWidth() { + let layout = TabBarLayout( + tabBarHeight: 28, + availableWidth: 800, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) + + XCTAssertEqual(layout.fullSplitButtonLaneWidth, 160) + XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 200) + XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 160) + XCTAssertEqual(layout.trailingTabContentInset, 160) + XCTAssertFalse(layout.splitButtonLaneOverflowsViewport) + } + + func testActionLaneSolidSurfaceCoversVisibleViewportWhenButtonsOverflow() { + let layout = TabBarLayout( + tabBarHeight: 28, + availableWidth: 240, + splitButtonCount: 12, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 400 + ) + let effect = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect( + solidWidth: 23.875, + solidSurfaceWidthAdjustment: -53, + contentOcclusionFraction: 0.6875 + ) + let geometry = TabBarActionLaneGeometry( + layout: layout, + effect: effect, + masksTabContent: true + ) + + XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 60, accuracy: 0.0001) + XCTAssertEqual(geometry.backgroundSolidWidth, 60, accuracy: 0.0001) + XCTAssertEqual(geometry.contentOcclusionWidth, 60, accuracy: 0.0001) + } + + func testActionLaneSolidSurfaceAllowsTrimWhenButtonsDoNotOverflow() { + let layout = TabBarLayout( + tabBarHeight: 28, + availableWidth: 800, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) + let effect = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect( + solidWidth: 23.875, + solidSurfaceWidthAdjustment: -53, + contentOcclusionFraction: 0.6875 + ) + let geometry = TabBarActionLaneGeometry( + layout: layout, + effect: effect, + masksTabContent: true + ) + + XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 160, accuracy: 0.0001) + XCTAssertEqual(geometry.backgroundSolidWidth, 107, accuracy: 0.0001) + XCTAssertEqual(geometry.contentOcclusionWidth, 110, accuracy: 0.0001) + } + + func testSplitButtonBackdropSolidSurfaceCoversVisibleActionLane() { + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 2, + visibleLaneWidth: 90, + solidSurfaceWidthAdjustment: 0 + ), + 90 + ) + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 96, + visibleLaneWidth: 72, + solidSurfaceWidthAdjustment: 0 + ), + 96 + ) + } + + func testSplitButtonContentOcclusionFractionDoesNotChangeSolidSurface() { + let occlusion = TabBarStyling.splitButtonContentOcclusionWidth( + visibleLaneWidth: 200, + contentOcclusionFraction: 0.25 + ) + + XCTAssertEqual(occlusion, 50) + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 2, + visibleLaneWidth: 200, + solidSurfaceWidthAdjustment: 0 + ), + 200 + ) + } + + func testSplitButtonBackdropSolidSurfaceWidthCanBeAdjusted() { + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 2, + visibleLaneWidth: 90, + solidSurfaceWidthAdjustment: 12 + ), + 102 + ) + XCTAssertEqual( + TabBarStyling.splitButtonBackdropSolidSurfaceWidth( + effectSolidWidth: 2, + visibleLaneWidth: 90, + solidSurfaceWidthAdjustment: -12 + ), + 78 + ) + } + + func testSplitButtonScrollAffordancesTrackHiddenButtons() { + var affordances = TabBarStyling.splitButtonScrollAffordances( + scrollOffset: 0, + contentWidth: 320, + viewportWidth: 60 + ) + XCTAssertFalse(affordances.left) + XCTAssertTrue(affordances.right) + + affordances = TabBarStyling.splitButtonScrollAffordances( + scrollOffset: 120, + contentWidth: 320, + viewportWidth: 60 + ) + XCTAssertTrue(affordances.left) + XCTAssertTrue(affordances.right) + + affordances = TabBarStyling.splitButtonScrollAffordances( + scrollOffset: 260, + contentWidth: 320, + viewportWidth: 60 + ) + XCTAssertTrue(affordances.left) + XCTAssertFalse(affordances.right) + + affordances = TabBarStyling.splitButtonScrollAffordances( + scrollOffset: 0, + contentWidth: 60, + viewportWidth: 60 + ) + XCTAssertFalse(affordances.left) + XCTAssertFalse(affordances.right) + } + + func testTabBarLayoutDoesNotHardClipSelectedChromeAtSplitButtonLane() { + let layout = TabBarLayout( + tabBarHeight: 28, + splitButtonCount: 4, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) + let indicatorFrame = layout.selectedIndicatorFrame( + selectedTabFrame: CGRect(x: 0, y: 0, width: 240, height: 28), + totalWidth: 240 + ) + XCTAssertNotNil(indicatorFrame) + XCTAssertEqual( + indicatorFrame?.maxX ?? 0, + 239, + accuracy: 0.001 + ) + } + + func testTabBarLayoutIgnoresMeasuredSplitButtonLaneWidthWithoutButtons() { + let layout = TabBarLayout( + tabBarHeight: 28, + splitButtonCount: 0, + splitButtonLaneVisible: false, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: 160 + ) + + XCTAssertEqual(layout.fullSplitButtonLaneWidth, 0) + XCTAssertEqual(layout.trailingTabContentInset, 0) + } + func testTabBarKeepsNonOverflowingTabsLeadingAligned() { let tabId = UUID() @@ -703,6 +942,7 @@ final class BonsplitTests: XCTestCase { fadeWidth: 80, contentFadeWidth: 42, solidWidth: 32, + solidSurfaceWidthAdjustment: 7, fadeRampStartFraction: 0.58, contentOcclusionFraction: 0.25 ) @@ -710,13 +950,19 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(effect.fadeWidth, 80) XCTAssertEqual(effect.contentFadeWidth, 42) XCTAssertEqual(effect.solidWidth, 32) + XCTAssertEqual(effect.solidSurfaceWidthAdjustment, 7) + XCTAssertNil(effect.separatorFadeWidth) XCTAssertEqual(effect.fadeRampStartFraction, 0.58) XCTAssertEqual(effect.contentOcclusionFraction, 0.25) let clamped = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect( + solidSurfaceWidthAdjustment: .infinity, + separatorFadeWidth: -4, fadeRampStartFraction: 1.4, contentOcclusionFraction: 2.2 ) + XCTAssertEqual(clamped.solidSurfaceWidthAdjustment, 0) + XCTAssertEqual(clamped.separatorFadeWidth, 0) XCTAssertEqual(clamped.fadeRampStartFraction, 0.95) XCTAssertEqual(clamped.contentOcclusionFraction, 1.0) } @@ -1405,6 +1651,133 @@ final class BonsplitTests: XCTestCase { XCTAssertFalse(controller.internalController.tabShortcutHintsEnabled) } + func testLegacyFileDropsOnlyValidateCenterZone() { + XCTAssertTrue( + UnifiedPaneDropDelegate.acceptsFileDrop( + zone: .center, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: true + ) + ) + XCTAssertFalse( + UnifiedPaneDropDelegate.acceptsFileDrop( + zone: .left, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: true + ) + ) + XCTAssertFalse( + UnifiedPaneDropDelegate.acceptsFileDrop( + zone: .center, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: false + ) + ) + XCTAssertTrue( + UnifiedPaneDropDelegate.acceptsFileDrop( + zone: .right, + hasExternalFileDropHandler: true, + hasLegacyFileDropHandler: false + ) + ) + } + + func testLegacyFileDropUpdatedRejectsEdgeZones() { + XCTAssertEqual( + UnifiedPaneDropDelegate.acceptedDropZone( + .center, + isFileDropOnly: true, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: true + ), + .center + ) + XCTAssertNil( + UnifiedPaneDropDelegate.acceptedDropZone( + .left, + isFileDropOnly: true, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: true + ) + ) + XCTAssertEqual( + UnifiedPaneDropDelegate.acceptedDropZone( + .left, + isFileDropOnly: true, + hasExternalFileDropHandler: true, + hasLegacyFileDropHandler: false + ), + .left + ) + XCTAssertEqual( + UnifiedPaneDropDelegate.acceptedDropZone( + .left, + isFileDropOnly: false, + hasExternalFileDropHandler: false, + hasLegacyFileDropHandler: false + ), + .left + ) + } + + func testFileURLPasteboardReaderReturnsFileURLs() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("bonsplit-file-drop-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let fileURL = directory.appendingPathComponent("sample.txt") + try "sample".write(to: fileURL, atomically: true, encoding: .utf8) + + let pasteboard = NSPasteboard(name: NSPasteboard.Name("bonsplit.file-drop.\(UUID().uuidString)")) + pasteboard.clearContents() + XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL])) + + XCTAssertEqual(UnifiedPaneDropDelegate.fileURLs(from: pasteboard), [fileURL]) + } + + func testFileDropValidationRequiresReadablePasteboardURLs() throws { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("bonsplit.file-drop.empty.\(UUID().uuidString)")) + pasteboard.clearContents() + + XCTAssertFalse(UnifiedPaneDropDelegate.hasReadableFileURLs(from: pasteboard)) + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("bonsplit-file-drop-readable-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let fileURL = directory.appendingPathComponent("sample.txt") + try "sample".write(to: fileURL, atomically: true, encoding: .utf8) + + XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL])) + XCTAssertTrue(UnifiedPaneDropDelegate.hasReadableFileURLs(from: pasteboard)) + } + + func testFileOnlyDropsDoNotUseStaleLocalTabDragState() { + XCTAssertFalse( + UnifiedPaneDropDelegate.shouldUseLocalTabDrag( + hasTabTransfer: false, + hasFileURL: true, + hasLocalTabDrag: true + ) + ) + XCTAssertTrue( + UnifiedPaneDropDelegate.shouldUseLocalTabDrag( + hasTabTransfer: true, + hasFileURL: false, + hasLocalTabDrag: true + ) + ) + XCTAssertFalse( + UnifiedPaneDropDelegate.shouldUseLocalTabDrag( + hasTabTransfer: true, + hasFileURL: false, + hasLocalTabDrag: false + ) + ) + } + func testSelectedTabNeverShowsHoverBackground() { XCTAssertFalse( TabItemStyling.shouldShowHoverBackground(isHovered: true, isSelected: true) @@ -1454,6 +1827,216 @@ final class BonsplitTests: XCTestCase { XCTAssertLessThan(unfocusedSaturation, 0.1) } + @MainActor + func testSplitButtonLaneDoesNotExposeSelectedTabIndicator() { + guard let saturation = renderedSplitButtonLaneTopSaturation() else { + XCTFail("Expected rendered split button lane colors") + return + } + + XCTAssertLessThan(saturation, 0.2) + } + + @MainActor + func testSplitButtonBackdropFadePaintsFullTabBarHeight() { + guard let delta = renderedSplitButtonBackdropFadeVerticalColorDelta() else { + XCTFail("Expected rendered split button backdrop fade colors") + return + } + + XCTAssertLessThan(delta, 0.08) + } + + @MainActor + func testSplitButtonBackdropSolidSurfaceCoversVisibleLane() { + guard let brightness = renderedSplitButtonLaneSolidBackdropBrightness() else { + XCTFail("Expected rendered split button lane backdrop color") + return + } + + XCTAssertGreaterThan(brightness, 0.5) + } + + @MainActor + func testSplitButtonBackdropDoesNotPaintSolidSurfaceAtTabContentFadeStart() { + guard let brightness = renderedSplitButtonContentFadeStartBackdropBrightness() else { + XCTFail("Expected rendered split button content fade backdrop color") + return + } + + XCTAssertLessThan(brightness, 0.1) + } + + @MainActor + func testSplitButtonBackdropOccludesTabChromeAtContentFadeStart() { + guard let saturation = renderedSplitButtonContentFadeStartSaturation() else { + XCTFail("Expected rendered split button content fade colors") + return + } + + XCTAssertLessThan(saturation, 0.2) + } + + @MainActor + func testSelectedTabIndicatorDoesNotBleedUnderSplitButtonBackdrop() { + guard let brightnesses = renderedSelectedIndicatorBackdropBrightnesses() else { + XCTFail("Expected rendered selected indicator backdrop colors") + return + } + + XCTAssertLessThan(brightnesses.leading, 0.08) + XCTAssertLessThan(brightnesses.trailing, 0.08) + } + + @MainActor + func testSharedBackdropTransparentActionLaneDoesNotPaintSyntheticSurface() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init( + backgroundHex: "#272822B8", + tabBarBackgroundHex: "#00000000", + splitButtonBackdropHex: "#00000000", + paneBackgroundHex: "#00000000" + ), + usesSharedBackdrop: true + ) + + XCTAssertFalse(TabBarColors.shouldPaintSplitButtonBackdrop(for: appearance)) + } + + @MainActor + func testSharedBackdropManySplitButtonsMaskTabContentWithoutSyntheticSurface() { + guard let alpha = renderedSharedBackdropActionLaneSurfaceAlpha() else { + XCTFail("Expected rendered shared backdrop action lane color") + return + } + + XCTAssertLessThan(alpha, 0.05) + } + + @MainActor + func testOverflowingSplitButtonsClipToActionLane() { + guard let brightness = renderedEscapedSplitButtonBrightnessOutsideActionLane() else { + XCTFail("Expected rendered split button overflow colors") + return + } + + XCTAssertLessThan(brightness, 0.30) + } + + @MainActor + func testSharedBackdropActionLaneBottomSeparatorCoversSolidAreaAndFadesOut() { + guard let alphas = renderedSharedBackdropActionLaneBottomSeparatorAlphas() else { + XCTFail("Expected rendered shared backdrop action lane separator colors") + return + } + + XCTAssertGreaterThan(alphas.solid, 0.25) + XCTAssertLessThan(alphas.fadeStart, alphas.solid * 0.25) + XCTAssertLessThan(alphas.beforeRamp, alphas.solid * 0.25) + XCTAssertGreaterThan(alphas.afterRamp, alphas.solid * 0.25) + XCTAssertGreaterThan(alphas.fadeEnd, alphas.solid * 0.55) + XCTAssertEqual(alphas.fadeEnd, alphas.solidStart, accuracy: 0.15) + } + + func testSharedBackdropActionLaneSeparatorMatchesBackdropGradientGeometry() { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let layout = TabBarLayout( + tabBarHeight: size.height, + availableWidth: size.width, + splitButtonCount: buttonCount, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + ) + let snapshot = TabBarChromeSnapshot( + appearance: sharedBackdropManyActionAppearance( + tabBarHeight: size.height, + buttonCount: buttonCount + ), + layout: layout, + isFocused: true, + shouldShowSplitButtons: true, + fadeColorStyle: 0 + ) + + XCTAssertEqual(snapshot.actionLaneSeparatorFadeWidth, snapshot.backdropFadeWidth, accuracy: 0.0001) + XCTAssertEqual(snapshot.actionLaneSeparatorSolidWidth, snapshot.actionLaneWidth, accuracy: 0.0001) + XCTAssertEqual(snapshot.backdropSolidWidth, snapshot.actionLaneWidth, accuracy: 0.0001) + XCTAssertEqual( + snapshot.actionLaneSeparatorFadeWidth + snapshot.actionLaneSeparatorSolidWidth, + snapshot.backdropFadeWidth + snapshot.actionLaneWidth, + accuracy: 0.0001 + ) + } + + func testSharedBackdropActionLaneSeparatorCanBeNarrowerThanContentFade() { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let layout = TabBarLayout( + tabBarHeight: size.height, + availableWidth: size.width, + splitButtonCount: buttonCount, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + ) + let appearance = sharedBackdropManyActionAppearance( + tabBarHeight: size.height, + buttonCount: buttonCount, + separatorFadeWidth: 12 + ) + let snapshot = TabBarChromeSnapshot( + appearance: appearance, + layout: layout, + isFocused: true, + shouldShowSplitButtons: true, + fadeColorStyle: 0 + ) + + XCTAssertEqual(snapshot.contentFadeWidth, 28.875, accuracy: 0.0001) + XCTAssertEqual(snapshot.actionLaneSeparatorFadeWidth, 12, accuracy: 0.0001) + XCTAssertLessThan(snapshot.actionLaneSeparatorFadeWidth, snapshot.contentFadeWidth) + } + + func testActionLaneFallbackSeparatorClipsToSelectedSeparatorGap() { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let layout = TabBarLayout( + tabBarHeight: size.height, + availableWidth: size.width, + splitButtonCount: buttonCount, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + ) + let snapshot = TabBarChromeSnapshot( + appearance: sharedBackdropManyActionAppearance( + tabBarHeight: size.height, + buttonCount: buttonCount, + separatorFadeWidth: 12 + ), + layout: layout, + isFocused: true, + shouldShowSplitButtons: true, + fadeColorStyle: 0 + ) + let geometry = snapshot.actionLaneGeometry + let mask = geometry.fallbackSeparatorMaskFrame( + totalWidth: size.width, + height: size.height, + selectedSeparatorGap: 300...340 + ) + + XCTAssertEqual(mask?.minX ?? -1, 300, accuracy: 0.0001) + XCTAssertEqual(mask?.maxX ?? -1, 340, accuracy: 0.0001) + XCTAssertNil(geometry.fallbackSeparatorMaskFrame( + totalWidth: size.width, + height: size.height, + selectedSeparatorGap: 0...40 + )) + } + func testTabBarSeparatorSegmentsClampGapIntoBounds() { var segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: -20...40) XCTAssertEqual(segments.left, 0, accuracy: 0.0001) @@ -2067,6 +2650,483 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedSplitButtonLaneTopSaturation() -> CGFloat? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#111111", + tabBarBackgroundHex: "#181818", + splitButtonBackdropHex: "#242424", + borderHex: "#666666" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the controls", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + let sampleRect = NSRect(x: laneStartX + 4, y: 0, width: 40, height: 4) + return maximumSaturation(in: hostingView, sampleRect: sampleRect) + } + } + + @MainActor + private func renderedSplitButtonBackdropFadeVerticalColorDelta() -> CGFloat? { + let size = NSSize(width: 360, height: 28) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#FFFFFF", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem(title: "", icon: nil) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let sampleX = size.width - 124 + guard let top = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: sampleX, y: 6) + )?.usingColorSpace(.sRGB), + let bottom = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: sampleX, y: size.height - 6) + )?.usingColorSpace(.sRGB) else { + return nil + } + + return abs(top.redComponent - bottom.redComponent) + + abs(top.greenComponent - bottom.greenComponent) + + abs(top.blueComponent - bottom.blueComponent) + + abs(top.alphaComponent - bottom.alphaComponent) + } + } + + @MainActor + private func renderedSplitButtonLaneSolidBackdropBrightness() -> CGFloat? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#FFFFFF", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem(title: "", icon: nil) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + guard let color = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX + 2, y: size.height / 2) + )?.usingColorSpace(.sRGB) else { + return nil + } + return brightness(of: color) + } + } + + @MainActor + private func renderedSplitButtonContentFadeStartBackdropBrightness() -> CGFloat? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let contentFadeWidth = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect.default.contentFadeWidth + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#FFFFFF", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the controls", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + guard let color = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX - contentFadeWidth + 2, y: size.height / 2) + )?.usingColorSpace(.sRGB) else { + return nil + } + return brightness(of: color) + } + } + + @MainActor + private func renderedSplitButtonContentFadeStartSaturation() -> CGFloat? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let contentFadeWidth = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect.default.contentFadeWidth + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#000000", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the controls", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + let sampleRect = NSRect( + x: laneStartX - contentFadeWidth + 2, + y: 0, + width: 8, + height: 4 + ) + return maximumSaturation(in: hostingView, sampleRect: sampleRect) + } + } + + @MainActor + private func renderedSelectedIndicatorBackdropBrightnesses() -> (leading: CGFloat, trailing: CGFloat)? { + let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count + let size = NSSize(width: 240, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let fadeWidth = BonsplitConfiguration.Appearance.SplitButtonBackdropEffect.default.contentFadeWidth + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + splitButtonBackdropEffect: .default, + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#000000", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem( + title: "selected tab title that reaches under the controls", + icon: nil + ) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + guard let leading = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX - fadeWidth + 4, y: 0) + )?.usingColorSpace(.sRGB), + let trailing = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX - 4, y: 0) + )?.usingColorSpace(.sRGB) else { + return nil + } + + return ( + leading: brightness(of: leading), + trailing: brightness(of: trailing) + ) + } + } + + @MainActor + private func renderedSharedBackdropActionLaneSurfaceAlpha() -> CGFloat? { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let appearance = sharedBackdropManyActionAppearance( + tabBarHeight: size.height, + buttonCount: buttonCount + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let leading = TabItem(title: "", icon: nil) + let selected = TabItem( + title: "selected tab title that reaches under the full action button lane", + icon: nil + ) + pane.tabs = [leading, selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let laneStartX = size.width - splitButtonLaneWidth + guard let color = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: laneStartX + 2, y: 2) + )?.usingColorSpace(.sRGB) else { + return nil + } + return color.alphaComponent + } + } + + @MainActor + private func renderedEscapedSplitButtonBrightnessOutsideActionLane() -> CGFloat? { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let appearance = BonsplitConfiguration.Appearance( + tabBarHeight: size.height, + tabMaxWidth: 40, + splitButtons: manySplitActionButtons(count: buttonCount), + splitButtonBackdropEffect: .init( + style: .translucentChrome, + fadeWidth: 99.75, + contentFadeWidth: 28.875, + solidWidth: 23.875, + fadeRampStartFraction: 0.60, + leadingOpacity: 0, + trailingOpacity: 0.8625, + contentOcclusionFraction: 0.6875, + masksTabContent: true + ), + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + splitButtonBackdropHex: "#000000", + borderHex: "#00000000" + ) + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let selected = TabItem(title: "", icon: nil) + pane.tabs = [selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + maximumBrightness( + in: hostingView, + sampleRect: NSRect( + x: 100, + y: 5, + width: size.width - splitButtonLaneWidth - 108, + height: size.height - 10 + ) + ) + } + } + + @MainActor + private func renderedSharedBackdropActionLaneBottomSeparatorAlphas() -> ( + fadeStart: CGFloat, + beforeRamp: CGFloat, + afterRamp: CGFloat, + fadeEnd: CGFloat, + solidStart: CGFloat, + solid: CGFloat + )? { + let buttonCount = 28 + let size = NSSize(width: 360, height: 28) + let splitButtonLaneWidth = visibleSplitButtonLaneWidth(size: size, buttonCount: buttonCount) + let separatorFadeWidth: CGFloat = 99.75 + let rampStartFraction: CGFloat = 0.60 + let contentOcclusionWidth = TabBarStyling.splitButtonContentOcclusionWidth( + visibleLaneWidth: splitButtonLaneWidth, + contentOcclusionFraction: 0.6875 + ) + let solidWidth = max(splitButtonLaneWidth, contentOcclusionWidth) + let appearance = sharedBackdropManyActionAppearance( + tabBarHeight: size.height, + buttonCount: buttonCount, + borderHex: "#FFFFFF80", + tabMaxWidth: size.width + ) + + return renderedTabBarValue( + isFocused: true, + appearance: appearance, + showSplitButtons: true, + size: size, + configurePane: { pane in + let leading = TabItem(title: "", icon: nil) + let selected = TabItem( + title: "selected tab title that reaches under the full action button lane", + icon: nil + ) + pane.tabs = [leading, selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let separatorY = size.height - 0.5 + let fadeStartX = size.width - solidWidth - separatorFadeWidth + let rampStartX = fadeStartX + separatorFadeWidth * rampStartFraction + let solidStartX = size.width - solidWidth + guard let fadeStart = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: fadeStartX + 2, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, + let beforeRamp = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: rampStartX - 2, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, + let afterRamp = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: rampStartX + 16, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, + let fadeEnd = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: solidStartX - 2, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, + let solidStart = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: solidStartX + 2, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent, + let solid = renderedColorInViewCoordinates( + in: hostingView, + at: NSPoint(x: size.width - 6, y: separatorY) + )?.usingColorSpace(.sRGB)?.alphaComponent else { + return nil + } + return ( + fadeStart: fadeStart, + beforeRamp: beforeRamp, + afterRamp: afterRamp, + fadeEnd: fadeEnd, + solidStart: solidStart, + solid: solid + ) + } + } + + private func sharedBackdropManyActionAppearance( + tabBarHeight: CGFloat, + buttonCount: Int, + borderHex: String = "#66666680", + tabMaxWidth: CGFloat = 220, + separatorFadeWidth: CGFloat? = nil + ) -> BonsplitConfiguration.Appearance { + BonsplitConfiguration.Appearance( + tabBarHeight: tabBarHeight, + tabMaxWidth: tabMaxWidth, + splitButtons: manySplitActionButtons(count: buttonCount), + splitButtonBackdropEffect: .init( + style: .translucentChrome, + fadeWidth: 99.75, + contentFadeWidth: 28.875, + solidWidth: 23.875, + separatorFadeWidth: separatorFadeWidth, + fadeRampStartFraction: 0.60, + leadingOpacity: 0, + trailingOpacity: 0.8625, + contentOcclusionFraction: 0.6875, + masksTabContent: true + ), + chromeColors: .init( + backgroundHex: "#242424B8", + tabBarBackgroundHex: "#00000000", + splitButtonBackdropHex: "#00000000", + paneBackgroundHex: "#00000000", + borderHex: borderHex + ), + usesSharedBackdrop: true + ) + } + + private func manySplitActionButtons(count: Int) -> [BonsplitConfiguration.SplitActionButton] { + (0.. CGFloat { + TabBarLayout( + tabBarHeight: size.height, + availableWidth: size.width, + splitButtonCount: buttonCount, + splitButtonLaneVisible: true, + reservesSplitButtonLane: true, + measuredSplitButtonLaneWidth: TabBarStyling.splitButtonsBackdropWidth(buttonCount: buttonCount) + ).visibleSplitButtonLaneWidth + } + @MainActor private func renderedSelectedTabLeftSeparatorAlphas() -> (top: CGFloat, bottom: CGFloat)? { let appearance = BonsplitConfiguration.Appearance( @@ -2118,11 +3178,12 @@ final class BonsplitTests: XCTestCase { y < bitmap.pixelsHigh else { return nil } return bitmap.colorAt(x: x, y: y) } - @MainActor private func renderedTabBarValue( isFocused: Bool, appearance: BonsplitConfiguration.Appearance = .default, + showSplitButtons: Bool = false, + size: NSSize? = nil, configurePane: ((PaneState) -> Void)? = nil, extract: (NSView) -> T? ) -> T? { @@ -2136,9 +3197,9 @@ final class BonsplitTests: XCTestCase { pane.selectedTabId = tab.id } - let size = NSSize(width: 160, height: TabBarMetrics.barHeight) + let size = size ?? NSSize(width: 160, height: TabBarMetrics.barHeight) let hostingView = NSHostingView( - rootView: TabBarView(pane: pane, isFocused: isFocused, showSplitButtons: false) + rootView: TabBarView(pane: pane, isFocused: isFocused, showSplitButtons: showSplitButtons) .environment(controller) .environment(controller.internalController) ) @@ -2188,9 +3249,10 @@ final class BonsplitTests: XCTestCase { guard let color = bitmap.colorAt(x: x, y: y), let rgb = color.usingColorSpace(.sRGB), rgb.alphaComponent > 0.05 else { continue } - let red = rgb.redComponent - let green = rgb.greenComponent - let blue = rgb.blueComponent + let alpha = min(max(rgb.alphaComponent, 0), 1) + let red = rgb.redComponent * alpha + let green = rgb.greenComponent * alpha + let blue = rgb.blueComponent * alpha let high = max(red, green, blue) guard high > 0.01 else { continue } let low = min(red, green, blue) @@ -2201,6 +3263,51 @@ final class BonsplitTests: XCTestCase { return maximum } + @MainActor + private func maximumBrightness(in view: NSView, sampleRect: NSRect) -> CGFloat? { + let integralBounds = view.bounds.integral + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: integralBounds) else { return nil } + bitmap.size = integralBounds.size + view.cacheDisplay(in: integralBounds, to: bitmap) + + let scaleX = CGFloat(bitmap.pixelsWide) / max(1, integralBounds.width) + let scaleY = CGFloat(bitmap.pixelsHigh) / max(1, integralBounds.height) + let minX = max(0, Int(floor(sampleRect.minX * scaleX))) + let maxX = min(bitmap.pixelsWide, Int(ceil(sampleRect.maxX * scaleX))) + let minY = max(0, Int(floor(sampleRect.minY * scaleY))) + let maxY = min(bitmap.pixelsHigh, Int(ceil(sampleRect.maxY * scaleY))) + + var maximum: CGFloat = 0 + for y in minY.. 0.05 else { continue } + let alpha = min(max(rgb.alphaComponent, 0), 1) + maximum = max( + maximum, + max( + rgb.redComponent * alpha, + rgb.greenComponent * alpha, + rgb.blueComponent * alpha + ) + ) + } + } + return maximum + } + + private func brightness(of color: NSColor) -> CGFloat { + guard let rgb = color.usingColorSpace(.sRGB) else { return 0 } + let alpha = min(max(rgb.alphaComponent, 0), 1) + guard alpha > 0.01 else { return 0 } + return max( + rgb.redComponent * alpha, + rgb.greenComponent * alpha, + rgb.blueComponent * alpha + ) + } + @MainActor private func highSaturationWidth(in view: NSView, sampleRect: NSRect) -> CGFloat? { let integralBounds = view.bounds.integral @@ -2222,9 +3329,13 @@ final class BonsplitTests: XCTestCase { guard let color = bitmap.colorAt(x: x, y: y), let rgb = color.usingColorSpace(.sRGB), rgb.alphaComponent > 0.05 else { continue } - let high = max(rgb.redComponent, rgb.greenComponent, rgb.blueComponent) + let alpha = min(max(rgb.alphaComponent, 0), 1) + let red = rgb.redComponent * alpha + let green = rgb.greenComponent * alpha + let blue = rgb.blueComponent * alpha + let high = max(red, green, blue) guard high > 0.01 else { continue } - let low = min(rgb.redComponent, rgb.greenComponent, rgb.blueComponent) + let low = min(red, green, blue) if (high - low) / high > 0.4 { hasIndicatorPixel = true break