diff --git a/Sources/Bonsplit/Internal/Models/SplitNode.swift b/Sources/Bonsplit/Internal/Models/SplitNode.swift index 24b208fa..706fb6c6 100644 --- a/Sources/Bonsplit/Internal/Models/SplitNode.swift +++ b/Sources/Bonsplit/Internal/Models/SplitNode.swift @@ -42,6 +42,25 @@ indirect enum SplitNode: Identifiable, Equatable { } } + /// The pane whose tab bar ends at the tree's top-trailing corner. + /// + /// Side-by-side splits take their trailing child and stacked splits their + /// top child, so the result's tab bar always touches the container's top + /// and trailing edges. + var topTrailingPaneId: PaneID { + switch self { + case .pane(let state): + return state.id + case .split(let state): + switch state.orientation { + case .horizontal: + return state.second.topTrailingPaneId + case .vertical: + return state.first.topTrailingPaneId + } + } + } + /// Get all pane IDs in the tree var allPaneIds: [PaneID] { switch self { diff --git a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 155f1fe4..1958abb9 100644 --- a/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -153,6 +153,7 @@ struct PaneContainerView: View { @Bindable var controller: SplitViewController let contentBuilder: (TabItem, PaneID) -> Content let emptyPaneBuilder: (PaneID) -> EmptyContent + var isMainContentFocused: Bool = true var showSplitButtons: Bool = true var tabBarVisibility: TabBarVisibility = .always var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch @@ -161,7 +162,7 @@ struct PaneContainerView: View { @State private var dropLifecycle: PaneDropLifecycle = .idle private var isFocused: Bool { - controller.focusedPaneId == pane.id + isMainContentFocused && controller.focusedPaneId == pane.id } private var isTabDragActive: Bool { diff --git a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift index 44cac619..d83d8bb2 100644 --- a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift @@ -207,6 +207,7 @@ struct SplitContainerView: NSViewRepresentabl let dividerPositionRange: ClosedRange let contentBuilder: (TabItem, PaneID) -> Content let emptyPaneBuilder: (PaneID) -> EmptyContent + var isMainContentFocused: Bool = true var showSplitButtons: Bool = true var tabBarVisibility: TabBarVisibility = .always var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch @@ -681,6 +682,7 @@ struct SplitContainerView: NSViewRepresentabl controller: controller, contentBuilder: contentBuilder, emptyPaneBuilder: emptyPaneBuilder, + isMainContentFocused: isMainContentFocused, showSplitButtons: showSplitButtons, tabBarVisibility: tabBarVisibility, contentViewLifecycle: contentViewLifecycle @@ -693,6 +695,7 @@ struct SplitContainerView: NSViewRepresentabl dividerPositionRange: dividerPositionRange, contentBuilder: contentBuilder, emptyPaneBuilder: emptyPaneBuilder, + isMainContentFocused: isMainContentFocused, showSplitButtons: showSplitButtons, tabBarVisibility: tabBarVisibility, contentViewLifecycle: contentViewLifecycle, diff --git a/Sources/Bonsplit/Internal/Views/SplitNodeView.swift b/Sources/Bonsplit/Internal/Views/SplitNodeView.swift index 5a495583..3e2011b1 100644 --- a/Sources/Bonsplit/Internal/Views/SplitNodeView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitNodeView.swift @@ -11,6 +11,7 @@ struct SplitNodeView: View { let appearance: BonsplitConfiguration.Appearance let dividerPositionRange: ClosedRange var showSplitButtons: Bool = true + var isMainContentFocused: Bool = true var tabBarVisibility: TabBarVisibility = .always var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch var onGeometryChange: ((_ isDragging: Bool) -> Void)? @@ -25,6 +26,7 @@ struct SplitNodeView: View { pane: paneState, contentBuilder: contentBuilder, emptyPaneBuilder: emptyPaneBuilder, + isMainContentFocused: isMainContentFocused, showSplitButtons: showSplitButtons, tabBarVisibility: tabBarVisibility, contentViewLifecycle: contentViewLifecycle @@ -38,6 +40,7 @@ struct SplitNodeView: View { dividerPositionRange: dividerPositionRange, contentBuilder: contentBuilder, emptyPaneBuilder: emptyPaneBuilder, + isMainContentFocused: isMainContentFocused, showSplitButtons: showSplitButtons, tabBarVisibility: tabBarVisibility, contentViewLifecycle: contentViewLifecycle, @@ -98,6 +101,7 @@ struct SinglePaneWrapper: NSViewRepresentable let pane: PaneState let contentBuilder: (TabItem, PaneID) -> Content let emptyPaneBuilder: (PaneID) -> EmptyContent + var isMainContentFocused: Bool = true var showSplitButtons: Bool = true var tabBarVisibility: TabBarVisibility = .always var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch @@ -108,6 +112,7 @@ struct SinglePaneWrapper: NSViewRepresentable controller: controller, contentBuilder: contentBuilder, emptyPaneBuilder: emptyPaneBuilder, + isMainContentFocused: isMainContentFocused, showSplitButtons: showSplitButtons, tabBarVisibility: tabBarVisibility, contentViewLifecycle: contentViewLifecycle @@ -165,6 +170,7 @@ struct SinglePaneWrapper: NSViewRepresentable controller: controller, contentBuilder: contentBuilder, emptyPaneBuilder: emptyPaneBuilder, + isMainContentFocused: isMainContentFocused, showSplitButtons: showSplitButtons, tabBarVisibility: tabBarVisibility, contentViewLifecycle: contentViewLifecycle diff --git a/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift b/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift index a08f47a1..11cd36c5 100644 --- a/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift +++ b/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift @@ -8,6 +8,7 @@ struct SplitViewContainer: View { let emptyPaneBuilder: (PaneID) -> EmptyContent let appearance: BonsplitConfiguration.Appearance let dividerPositionRange: ClosedRange + var isMainContentFocused: Bool = true var showSplitButtons: Bool = true var tabBarVisibility: TabBarVisibility = .always var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch @@ -48,6 +49,7 @@ struct SplitViewContainer: View { appearance: appearance, dividerPositionRange: dividerPositionRange, showSplitButtons: showSplitButtons, + isMainContentFocused: isMainContentFocused, tabBarVisibility: tabBarVisibility, contentViewLifecycle: contentViewLifecycle, onGeometryChange: onGeometryChange, diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index b1d10b93..4bb4d8fc 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -1172,7 +1172,38 @@ struct TabBarView: View { .frame(height: tabBarHeight, alignment: .topLeading) } + /// Width reserved after this tab bar's action lane for a host-owned control. + /// Only the pane at the container's top-trailing corner reserves it. + private var reservedTrailingInset: CGFloat { + let inset = appearance.tabBarTrailingInset + guard inset > 0 else { return 0 } + let cornerPaneId = splitViewController.zoomedPaneId + ?? controller.internalController.rootNode.topTrailingPaneId + return cornerPaneId == pane.id ? inset : 0 + } + var body: some View { + let trailingInset = reservedTrailingInset + tabBarContent + .padding(.trailing, trailingInset) + .background(alignment: .trailing) { + if trailingInset > 0 { + TabBarDragZoneView( + isMinimalMode: isMinimalMode, + isFocusedPane: isFocused, + onSingleClick: focusPaneFromTabBarChrome + ) { return false } + .frame(width: trailingInset, height: tabBarHeight) + .background( + TabBarLayerBackedColor(color: chromeSnapshot.barColor) + .frame(width: trailingInset, height: tabBarHeight) + ) + .accessibilityIdentifier("paneTabBar.trailingInset") + } + } + } + + private var tabBarContent: some View { HStack(spacing: 0) { if appearance.tabBarLeadingInset > 0 && controller.internalController.rootNode.allPaneIds.first == pane.id { TabBarDragZoneView( diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration.swift b/Sources/Bonsplit/Public/BonsplitConfiguration.swift index d4ee6512..a08ee9cf 100644 --- a/Sources/Bonsplit/Public/BonsplitConfiguration.swift +++ b/Sources/Bonsplit/Public/BonsplitConfiguration.swift @@ -595,6 +595,11 @@ extension BonsplitConfiguration { /// Extra leading inset for the tab bar (e.g. for traffic light buttons when sidebar is collapsed) public var tabBarLeadingInset: CGFloat + /// Extra trailing inset reserved at the end of the top-trailing pane's tab bar, + /// after its action buttons, for a host-owned control drawn over that corner. + /// A zoomed pane receives the inset because it fills the container. + public var tabBarTrailingInset: CGFloat + /// Tooltip text for the tab bar's right-side action buttons public var splitButtonTooltips: SplitButtonTooltips @@ -654,6 +659,7 @@ extension BonsplitConfiguration { splitButtonBackdropStyle: SplitButtonBackdropStyle? = nil, splitButtonBackdropEffect: SplitButtonBackdropEffect? = nil, tabBarLeadingInset: CGFloat = 0, + tabBarTrailingInset: CGFloat = 0, splitButtonTooltips: SplitButtonTooltips = .default, animationDuration: Double = 0.15, enableAnimations: Bool = true, @@ -676,6 +682,7 @@ extension BonsplitConfiguration { self.splitButtonBackdropStyle = splitButtonBackdropStyle self.splitButtonBackdropEffect = splitButtonBackdropEffect self.tabBarLeadingInset = tabBarLeadingInset + self.tabBarTrailingInset = tabBarTrailingInset self.splitButtonTooltips = splitButtonTooltips self.animationDuration = animationDuration self.enableAnimations = enableAnimations diff --git a/Sources/Bonsplit/Public/BonsplitView.swift b/Sources/Bonsplit/Public/BonsplitView.swift index 79d5471c..c0d6fbfa 100644 --- a/Sources/Bonsplit/Public/BonsplitView.swift +++ b/Sources/Bonsplit/Public/BonsplitView.swift @@ -19,6 +19,10 @@ import SwiftUI /// ``` public struct BonsplitView: View { @Bindable private var controller: BonsplitController + /// Whether the main pane currently owns keyboard focus. Selection remains + /// owned by Bonsplit, but a host may move focus to an adjacent surface + /// while keeping the selected pane alive. + private let isMainContentFocused: Bool private let contentBuilder: (Tab, PaneID) -> Content private let emptyPaneBuilder: (PaneID) -> EmptyContent @@ -29,10 +33,12 @@ public struct BonsplitView: View { /// - emptyPane: A ViewBuilder closure that provides content for empty panes public init( controller: BonsplitController, + isMainContentFocused: Bool = true, @ViewBuilder content: @escaping (Tab, PaneID) -> Content, @ViewBuilder emptyPane: @escaping (PaneID) -> EmptyContent ) { self.controller = controller + self.isMainContentFocused = isMainContentFocused self.contentBuilder = content self.emptyPaneBuilder = emptyPane } @@ -47,6 +53,7 @@ public struct BonsplitView: View { }, appearance: controller.configuration.appearance, dividerPositionRange: controller.configuration.dividerPositionRange, + isMainContentFocused: isMainContentFocused, showSplitButtons: controller.configuration.allowSplits && controller.configuration.appearance.showSplitButtons, tabBarVisibility: controller.configuration.tabBarVisibility, contentViewLifecycle: controller.configuration.contentViewLifecycle, @@ -70,9 +77,11 @@ extension BonsplitView where EmptyContent == DefaultEmptyPaneView { /// - content: A ViewBuilder closure that provides content for each tab. Receives the tab and pane ID. public init( controller: BonsplitController, + isMainContentFocused: Bool = true, @ViewBuilder content: @escaping (Tab, PaneID) -> Content ) { self.controller = controller + self.isMainContentFocused = isMainContentFocused self.contentBuilder = content self.emptyPaneBuilder = { _ in DefaultEmptyPaneView() } } diff --git a/Tests/BonsplitTests/TopTrailingPaneTests.swift b/Tests/BonsplitTests/TopTrailingPaneTests.swift new file mode 100644 index 00000000..c5aaf382 --- /dev/null +++ b/Tests/BonsplitTests/TopTrailingPaneTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import Bonsplit + +/// The top-trailing pane owns the host's reserved tab-bar trailing inset. +@MainActor +final class TopTrailingPaneTests: XCTestCase { + func testSinglePaneIsTopTrailing() throws { + let controller = BonsplitController() + let pane = try XCTUnwrap(controller.allPaneIds.first) + XCTAssertEqual(controller.internalController.rootNode.topTrailingPaneId, pane) + } + + func testSideBySideSplitUsesTrailingPane() throws { + let controller = BonsplitController() + let leading = try XCTUnwrap(controller.allPaneIds.first) + let trailing = try XCTUnwrap(controller.splitPane(leading, orientation: .horizontal)) + XCTAssertEqual(controller.internalController.rootNode.topTrailingPaneId, trailing) + } + + func testStackedSplitUsesTopPane() throws { + let controller = BonsplitController() + let top = try XCTUnwrap(controller.allPaneIds.first) + _ = try XCTUnwrap(controller.splitPane(top, orientation: .vertical)) + XCTAssertEqual(controller.internalController.rootNode.topTrailingPaneId, top) + } + + func testNestedSplitUsesTopOfTrailingColumn() throws { + let controller = BonsplitController() + let leading = try XCTUnwrap(controller.allPaneIds.first) + let trailingTop = try XCTUnwrap(controller.splitPane(leading, orientation: .horizontal)) + _ = try XCTUnwrap(controller.splitPane(trailingTop, orientation: .vertical)) + XCTAssertEqual(controller.internalController.rootNode.topTrailingPaneId, trailingTop) + } +}