Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 40 additions & 27 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,6 @@ private struct TabItemHitRegionView: NSViewRepresentable {
}
}

private struct SelectedTabFramePreferenceKey: PreferenceKey {
static let defaultValue: CGRect? = nil

static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
if let next = nextValue() {
value = next
}
}
}

private struct TabFramePreferenceKey: PreferenceKey {
static let defaultValue: [UUID: CGRect] = [:]

Expand Down Expand Up @@ -367,6 +357,14 @@ enum TabBarStyling {
SplitActionButtonImageCache.shared.image(for: data)
}

static func selectedTabFrame(
selectedTabId: UUID?,
tabFrames: [UUID: CGRect]
) -> CGRect? {
guard let selectedTabId else { return nil }
return tabFrames[selectedTabId]
}

enum ScrollTarget: Equatable {
case leading
case selectedTab(UUID)
Expand Down Expand Up @@ -480,14 +478,15 @@ struct TabBarLayout: Equatable {
}

var maximumSplitButtonLaneWidth: CGFloat {
guard availableWidth > 0 else { return fullSplitButtonLaneWidth }
guard availableWidth > 0 else { return 0 }
let fractionLimit = availableWidth * TabBarStyling.maximumSplitButtonLaneWidthFraction
return max(fractionLimit, trailingWhitespaceBeforeSplitButtonLane)
}

var trailingWhitespaceBeforeSplitButtonLane: CGFloat {
guard availableWidth > 0,
let tabContentWidthExcludingSplitButtonLane else {
let tabContentWidthExcludingSplitButtonLane,
tabContentWidthExcludingSplitButtonLane > 0 else {
return 0
}
return max(0, availableWidth - tabContentWidthExcludingSplitButtonLane)
Expand Down Expand Up @@ -891,7 +890,6 @@ struct TabBarView: View {
@State private var contentWidth: CGFloat = 0
@State private var tabContentWidthExcludingSplitButtonLane: CGFloat?
@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
Expand Down Expand Up @@ -979,6 +977,13 @@ struct TabBarView: View {
tabBarLayout.trailingTabContentInset
}

private var selectedTabFrameInBar: CGRect? {
TabBarStyling.selectedTabFrame(
selectedTabId: pane.selectedTabId,
tabFrames: tabFramesInBar
)
}

private var leadingScrollAnchorId: String {
"tab-bar-leading-\(pane.id.id.uuidString)"
}
Expand Down Expand Up @@ -1147,7 +1152,7 @@ struct TabBarView: View {
splitButtonBackdropChrome
.opacity(shouldShowSplitButtons ? 1 : 0)
.allowsHitTesting(false)
.animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons)
.tabBarButtonAnimationsDisabled()
}
.overlay(maskedTabBarBottomSeparatorChrome)
.overlay {
Expand Down Expand Up @@ -1179,10 +1184,10 @@ struct TabBarView: View {
onDoubleClick: {
performNewTerminalSplitButtonAction()
},
onHoverChanged: { isHoveringTabBar = $0 }
onHoverChanged: { updateTabBarHover($0) }
))
.overlay(
TabBarHoverTrackingView { isHoveringTabBar = $0 }
TabBarHoverTrackingView { updateTabBarHover($0) }
)
.overlay(
TabBarManualReorderTrackingView(
Expand Down Expand Up @@ -1217,11 +1222,10 @@ struct TabBarView: View {
.onAppear {
controlKeyMonitor.start()
}
.onPreferenceChange(SelectedTabFramePreferenceKey.self) { frame in
selectedTabFrameInBar = frame
}
.onPreferenceChange(TabFramePreferenceKey.self) { frames in
tabFramesInBar = frames
withTransaction(Transaction(animation: nil)) {
tabFramesInBar = frames
}
}
.onPreferenceChange(SplitButtonLaneWidthPreferenceKey.self) { width in
measuredSplitButtonLaneWidth = width
Expand All @@ -1233,6 +1237,12 @@ struct TabBarView: View {

// MARK: - Tab Item

private func updateTabBarHover(_ hovering: Bool) {
withTransaction(Transaction(animation: nil)) {
isHoveringTabBar = hovering
}
}

@ViewBuilder
private func tabItem(for tab: TabItem, at index: Int) -> some View {
let contextMenuState = contextMenuState(for: tab, at: index)
Expand Down Expand Up @@ -1293,10 +1303,6 @@ struct TabBarView: View {
GeometryReader { geometry in
let frame = geometry.frame(in: .named("tabBar"))
Color.clear
.preference(
key: SelectedTabFramePreferenceKey.self,
value: pane.selectedTabId == tab.id ? frame : nil
)
.preference(
key: TabFramePreferenceKey.self,
value: [tab.id: frame]
Expand Down Expand Up @@ -1489,11 +1495,17 @@ struct TabBarView: View {
private var splitButtonChrome: some View {
if shouldRenderSplitButtons {
splitButtons
.frame(width: splitButtonsBackdropWidth, height: tabBarHeight, alignment: .trailing)
.mask {
Rectangle()
.frame(width: splitButtonsBackdropWidth, height: tabBarHeight)
}
.clipped()
.saturation(tabBarSaturation)
.opacity(shouldShowSplitButtons ? 1 : 0)
.allowsHitTesting(shouldShowSplitButtons)
.frame(height: tabBarHeight, alignment: .trailing)
.animation(.easeInOut(duration: 0.14), value: shouldShowSplitButtons)
.frame(height: tabBarHeight, alignment: .trailing)
.tabBarButtonAnimationsDisabled()
}
}

Expand Down Expand Up @@ -1645,6 +1657,7 @@ struct TabBarView: View {
}
.frame(width: laneWidth, height: tabBarHeight, alignment: .trailing)
.contentShape(Rectangle())
.compositingGroup()
.clipped()
}

Expand Down Expand Up @@ -2001,7 +2014,7 @@ private struct SplitActionButtonStyle: ButtonStyle {
.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)
.tabBarButtonAnimationsDisabled()
}
}

Expand Down
23 changes: 17 additions & 6 deletions Sources/Bonsplit/Internal/Views/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ extension View {
func tabControlShortcutHintVisibilityAnimation<Value: Equatable>(value: Value) -> some View {
animation(TabControlShortcutHintAnimation.visibility, value: value)
}

func tabBarButtonAnimationsDisabled() -> some View {
transaction { transaction in
transaction.animation = nil
}
}
}

private enum TabControlShortcutHintDebugSettings {
Expand Down Expand Up @@ -170,10 +176,13 @@ struct TabItemView: View {
}
.buttonStyle(.plain)
.onHover { hovering in
isZoomHovered = hovering
withTransaction(Transaction(animation: nil)) {
isZoomHovered = hovering
}
}
.saturation(saturation)
.accessibilityLabel("Exit zoom")
.tabBarButtonAnimationsDisabled()
}
}

Expand Down Expand Up @@ -212,8 +221,9 @@ struct TabItemView: View {
}
)
.onHover { hovering in
// Keep icon rendering stable while hovering; only accessory/background elements animate.
isHovered = hovering
withTransaction(Transaction(animation: nil)) {
isHovered = hovering
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(tab.title)
Expand Down Expand Up @@ -443,14 +453,15 @@ struct TabItemView: View {
}
.buttonStyle(.plain)
.onHover { hovering in
isCloseHovered = hovering
withTransaction(Transaction(animation: nil)) {
isCloseHovered = hovering
}
}
.saturation(saturation)
}
}
.frame(width: accessorySlotSize, height: accessorySlotSize)
.animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: isHovered)
.animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: isCloseHovered)
.tabBarButtonAnimationsDisabled()
}
}

Expand Down
74 changes: 71 additions & 3 deletions Tests/BonsplitTests/BonsplitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ final class BonsplitTests: XCTestCase {
func testTabBarLayoutExpandsForMeasuredSplitButtonLaneWidth() {
let layout = TabBarLayout(
tabBarHeight: 28,
availableWidth: 800,
splitButtonCount: 4,
splitButtonLaneVisible: true,
reservesSplitButtonLane: true,
Expand Down Expand Up @@ -450,6 +451,22 @@ final class BonsplitTests: XCTestCase {
XCTAssertTrue(layout.splitButtonLaneOverflowsViewport)
}

func testTabBarLayoutDoesNotTreatZeroTabContentAsTrailingWhitespace() {
let layout = TabBarLayout(
tabBarHeight: 28,
availableWidth: 240,
tabContentWidthExcludingSplitButtonLane: 0,
splitButtonCount: 12,
splitButtonLaneVisible: true,
reservesSplitButtonLane: true,
measuredSplitButtonLaneWidth: 400
)

XCTAssertEqual(layout.maximumSplitButtonLaneWidth, 60)
XCTAssertEqual(layout.visibleSplitButtonLaneWidth, 60)
XCTAssertEqual(layout.trailingTabContentInset, 60)
}

func testTabBarLayoutUsesTrailingWhitespaceBeforeClippingSplitButtons() {
let measuredWidth = TabBarStyling.splitButtonsBackdropWidth(buttonCount: 10)
let layout = TabBarLayout(
Expand Down Expand Up @@ -644,6 +661,56 @@ final class BonsplitTests: XCTestCase {
)
}

func testTabBarSelectedChromeFrameFollowsCurrentSelection() {
let firstTabId = UUID()
let secondTabId = UUID()
let layout = TabBarLayout(
tabBarHeight: 28,
splitButtonCount: 0,
splitButtonLaneVisible: false,
reservesSplitButtonLane: false
)
let frames = [
firstTabId: CGRect(x: 12, y: 0, width: 120, height: 28),
secondTabId: CGRect(x: 144, y: 0, width: 96, height: 28),
]
let totalWidth: CGFloat = 300

let firstSelectedFrame = TabBarStyling.selectedTabFrame(
selectedTabId: firstTabId,
tabFrames: frames
)
let secondSelectedFrame = TabBarStyling.selectedTabFrame(
selectedTabId: secondTabId,
tabFrames: frames
)
let firstIndicatorFrame = layout.selectedIndicatorFrame(
selectedTabFrame: firstSelectedFrame,
totalWidth: totalWidth
)
let secondIndicatorFrame = layout.selectedIndicatorFrame(
selectedTabFrame: secondSelectedFrame,
totalWidth: totalWidth
)

XCTAssertEqual(firstIndicatorFrame?.minX, frames[firstTabId]?.minX)
XCTAssertEqual(
secondIndicatorFrame?.minX,
frames[secondTabId]?.minX,
"Selected tab chrome must be derived from the current selected tab id, not a cached frame from a previous selection."
)
let nilSelectedFrame = TabBarStyling.selectedTabFrame(
selectedTabId: nil,
tabFrames: frames
)
XCTAssertNil(
layout.selectedIndicatorFrame(
selectedTabFrame: nilSelectedFrame,
totalWidth: totalWidth
)
)
}

func testTabBarLayoutIgnoresMeasuredSplitButtonLaneWidthWithoutButtons() {
let layout = TabBarLayout(
tabBarHeight: 28,
Expand Down Expand Up @@ -3383,17 +3450,17 @@ final class BonsplitTests: XCTestCase {
showSplitButtons: true,
size: size,
configurePane: { pane in
let tabs = (0..<8).map { _ in TabItem(title: "", icon: nil) }
let tabs = (0..<16).map { _ in TabItem(title: "", icon: nil) }
pane.tabs = tabs
pane.selectedTabId = tabs.first?.id
}
) { hostingView in
maximumBrightness(
in: hostingView,
sampleRect: NSRect(
x: 100,
x: size.width - splitButtonLaneWidth - 16,
y: 5,
width: size.width - splitButtonLaneWidth - 108,
width: 8,
height: size.height - 10
)
)
Expand Down Expand Up @@ -3550,6 +3617,7 @@ final class BonsplitTests: XCTestCase {
return renderedTabBarValue(
isFocused: true,
appearance: appearance,
size: NSSize(width: 320, height: TabBarMetrics.barHeight),
configurePane: { pane in
let leading = TabItem(title: "", icon: nil)
let selected = TabItem(title: "", icon: nil)
Expand Down
Loading