Skip to content
Open
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
19 changes: 19 additions & 0 deletions Sources/Bonsplit/Internal/Models/SplitNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion Sources/Bonsplit/Internal/Views/PaneContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ struct PaneContainerView<Content: View, EmptyContent: View>: 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
Expand All @@ -161,7 +162,7 @@ struct PaneContainerView<Content: View, EmptyContent: View>: View {
@State private var dropLifecycle: PaneDropLifecycle = .idle

private var isFocused: Bool {
controller.focusedPaneId == pane.id
isMainContentFocused && controller.focusedPaneId == pane.id
}

private var isTabDragActive: Bool {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
let dividerPositionRange: ClosedRange<CGFloat>
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
Expand Down Expand Up @@ -681,6 +682,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
controller: controller,
contentBuilder: contentBuilder,
emptyPaneBuilder: emptyPaneBuilder,
isMainContentFocused: isMainContentFocused,
showSplitButtons: showSplitButtons,
tabBarVisibility: tabBarVisibility,
contentViewLifecycle: contentViewLifecycle
Expand All @@ -693,6 +695,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
dividerPositionRange: dividerPositionRange,
contentBuilder: contentBuilder,
emptyPaneBuilder: emptyPaneBuilder,
isMainContentFocused: isMainContentFocused,
showSplitButtons: showSplitButtons,
tabBarVisibility: tabBarVisibility,
contentViewLifecycle: contentViewLifecycle,
Expand Down
6 changes: 6 additions & 0 deletions Sources/Bonsplit/Internal/Views/SplitNodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct SplitNodeView<Content: View, EmptyContent: View>: View {
let appearance: BonsplitConfiguration.Appearance
let dividerPositionRange: ClosedRange<CGFloat>
var showSplitButtons: Bool = true
var isMainContentFocused: Bool = true
var tabBarVisibility: TabBarVisibility = .always
var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch
var onGeometryChange: ((_ isDragging: Bool) -> Void)?
Expand All @@ -25,6 +26,7 @@ struct SplitNodeView<Content: View, EmptyContent: View>: View {
pane: paneState,
contentBuilder: contentBuilder,
emptyPaneBuilder: emptyPaneBuilder,
isMainContentFocused: isMainContentFocused,
showSplitButtons: showSplitButtons,
tabBarVisibility: tabBarVisibility,
contentViewLifecycle: contentViewLifecycle
Expand All @@ -38,6 +40,7 @@ struct SplitNodeView<Content: View, EmptyContent: View>: View {
dividerPositionRange: dividerPositionRange,
contentBuilder: contentBuilder,
emptyPaneBuilder: emptyPaneBuilder,
isMainContentFocused: isMainContentFocused,
showSplitButtons: showSplitButtons,
tabBarVisibility: tabBarVisibility,
contentViewLifecycle: contentViewLifecycle,
Expand Down Expand Up @@ -98,6 +101,7 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: 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
Expand All @@ -108,6 +112,7 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
controller: controller,
contentBuilder: contentBuilder,
emptyPaneBuilder: emptyPaneBuilder,
isMainContentFocused: isMainContentFocused,
showSplitButtons: showSplitButtons,
tabBarVisibility: tabBarVisibility,
contentViewLifecycle: contentViewLifecycle
Expand Down Expand Up @@ -165,6 +170,7 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
controller: controller,
contentBuilder: contentBuilder,
emptyPaneBuilder: emptyPaneBuilder,
isMainContentFocused: isMainContentFocused,
showSplitButtons: showSplitButtons,
tabBarVisibility: tabBarVisibility,
contentViewLifecycle: contentViewLifecycle
Expand Down
2 changes: 2 additions & 0 deletions Sources/Bonsplit/Internal/Views/SplitViewContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct SplitViewContainer<Content: View, EmptyContent: View>: View {
let emptyPaneBuilder: (PaneID) -> EmptyContent
let appearance: BonsplitConfiguration.Appearance
let dividerPositionRange: ClosedRange<CGFloat>
var isMainContentFocused: Bool = true
var showSplitButtons: Bool = true
var tabBarVisibility: TabBarVisibility = .always
var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch
Expand Down Expand Up @@ -48,6 +49,7 @@ struct SplitViewContainer<Content: View, EmptyContent: View>: View {
appearance: appearance,
dividerPositionRange: dividerPositionRange,
showSplitButtons: showSplitButtons,
isMainContentFocused: isMainContentFocused,
tabBarVisibility: tabBarVisibility,
contentViewLifecycle: contentViewLifecycle,
onGeometryChange: onGeometryChange,
Expand Down
31 changes: 31 additions & 0 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions Sources/Bonsplit/Public/BonsplitConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Sources/Bonsplit/Public/BonsplitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import SwiftUI
/// ```
public struct BonsplitView<Content: View, EmptyContent: View>: 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

Expand All @@ -29,10 +33,12 @@ public struct BonsplitView<Content: View, EmptyContent: View>: 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
}
Expand All @@ -47,6 +53,7 @@ public struct BonsplitView<Content: View, EmptyContent: View>: 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,
Expand All @@ -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() }
}
Expand Down
34 changes: 34 additions & 0 deletions Tests/BonsplitTests/TopTrailingPaneTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading