Bonsplit
#Bonsplit
A native macOS tab bar library with split pane support for SwiftUI applications.
#Features
- Native macOS look and feel using system colors
- Drag-and-drop tab reordering within and between panes
- Horizontal and vertical split panes with smooth 120fps animations
- Configurable appearance and behavior
- Delegate callbacks for all tab and pane events
- Keyboard navigation between panes
- Optional macOS-like tab state preservation (scroll position, focus, @State)
#Requirements
- macOS 14.0+
- Swift 5.9+
- Xcode 15.0+
#Installation
#Swift Package Manager
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/almonk/bonsplit.git", from: "1.1.1")
]
Or in Xcode: File → Add Package Dependencies → Enter the repository URL.
#Quick Start
import SwiftUI
import Bonsplit
struct ContentView: View {
@State private var controller = BonsplitController()
@State private var documents: [TabID: Document] = [:]
var body: some View {
BonsplitView(controller: controller) { tab in
// Content for each tab
if let document = documents[tab.id] {
DocumentEditor(document: document)
}
} emptyPane: { paneId in
// Custom view for empty panes (optional)
VStack {
Text("No Open Files")
Button("New File") {
createDocument(inPane: paneId)
}
}
}
.onAppear {
// Create initial tab
if let tabId = controller.createTab(title: "Untitled", icon: "doc.text") {
documents[tabId] = Document()
}
}
}
}
Note: Splits create empty panes by default, giving you full control. Use the didSplitPane delegate method to auto-create tabs if desired.
#API Reference
#BonsplitController
The main controller for managing tabs and panes.
#Tab Operations
// Create a new tab
let tabId = controller.createTab(
title: "Document.swift",
icon: "swift", // SF Symbol name (optional)
isDirty: false, // Show dirty indicator (optional)
inPane: paneId // Target pane (optional, defaults to focused)
)
// Update tab properties
controller.updateTab(tabId, title: "NewName.swift")
controller.updateTab(tabId, isDirty: true)
controller.updateTab(tabId, icon: "doc.text")
// Close a tab
controller.closeTab(tabId)
// Select a tab
controller.selectTab(tabId)
// Navigate tabs
controller.selectPreviousTab()
controller.selectNextTab()
#Split Operations
// Split the focused pane (creates empty pane) let newPaneId = controller.splitPane(orientation: .horizontal) // Side-by-side let newPaneId = controller.splitPane(orientation: .vertical) // Stacked // Split a specific pane controller.splitPane(paneId, orientation: .horizontal) // Split with a tab already in the new pane controller.splitPane(orientation: .horizontal, withTab: Tab(title: "New", icon: "doc")) // Close a pane controller.closePane(paneId)
Note: By default, splitPane() creates an empty pane. You have full control over when and how to add tabs. Use the didSplitPane delegate callback to create a tab in the new pane if you want automatic tab creation.
#Focus Management
// Get focused pane let focusedPane = controller.focusedPaneId // Focus a specific pane controller.focusPane(paneId) // Navigate between panes controller.navigateFocus(direction: .left) controller.navigateFocus(direction: .right) controller.navigateFocus(direction: .up) controller.navigateFocus(direction: .down)
#Query Methods
// Get all tabs
let allTabs = controller.allTabIds
// Get all panes
let allPanes = controller.allPaneIds
// Get tab info
if let tab = controller.tab(tabId) {
print(tab.title, tab.icon, tab.isDirty)
}
// Get tabs in a pane
let paneTabs = controller.tabs(inPane: paneId)
// Get selected tab in a pane
let selected = controller.selectedTab(inPane: paneId)
#Geometry & Synchronization
Query pane geometry and save/restore layout configurations:
// Get flat list of pane geometries with pixel coordinates
let snapshot = controller.layoutSnapshot()
for pane in snapshot.panes {
print("Pane \(pane.paneId): \(pane.frame.width)x\(pane.frame.height)")
}
// Get full tree structure
let tree = controller.treeSnapshot()
// Set divider position programmatically (0.0-1.0)
controller.setDividerPosition(0.3, forSplit: splitId, fromExternal: true)
// Update container frame when window moves
controller.setContainerFrame(newFrame)
| Method | Description |
|---|---|
layoutSnapshot() |
Get current pane geometry with pixel coordinates |
treeSnapshot() |
Get full tree structure for external consumption |
findSplit(_:) |
Check if a split exists by UUID |
setDividerPosition(_:forSplit:fromExternal:) |
Programmatically set divider position |
setContainerFrame(_:) |
Update container frame |
#Tab
Read-only snapshot of tab metadata.
public struct Tab {
public let id: TabID
public let title: String
public let icon: String?
public let isDirty: Bool
}
#BonsplitDelegate
Implement this protocol to receive callbacks about tab bar events.
class MyDelegate: BonsplitDelegate {
// Veto tab creation
func splitTabBar(_ controller: BonsplitController,
shouldCreateTab tab: Tab,
inPane pane: PaneID) -> Bool {
return true // Return false to prevent
}
// Veto tab close (e.g., prompt to save)
func splitTabBar(_ controller: BonsplitController,
shouldCloseTab tab: Tab,
inPane pane: PaneID) -> Bool {
if tab.isDirty {
return showSaveConfirmation()
}
return true
}
// React to tab selection
func splitTabBar(_ controller: BonsplitController,
didSelectTab tab: Tab,
inPane pane: PaneID) {
updateWindowTitle(tab.title)
}
// React to splits - new panes are empty by default
func splitTabBar(_ controller: BonsplitController,
didSplitPane originalPane: PaneID,
newPane: PaneID,
orientation: SplitOrientation) {
// Option 1: Auto-create a tab
controller.createTab(title: "Untitled", icon: "doc.text", inPane: newPane)
// Option 2: Leave empty - the emptyPane view will be shown
}
}
All delegate methods have default implementations and are optional.
#Available Delegate Methods
| Method | Description |
|---|---|
shouldCreateTab |
Called before creating a tab. Return false to prevent. |
didCreateTab |
Called after a tab is created. |
shouldCloseTab |
Called before closing a tab. Return false to prevent. |
didCloseTab |
Called after a tab is closed. |
didSelectTab |
Called when a tab is selected. |
didMoveTab |
Called when a tab is moved between panes. |
shouldSplitPane |
Called before creating a split. Return false to prevent. |
didSplitPane |
Called after a split is created. Use this to create a tab in the new empty pane. |
shouldClosePane |
Called before closing a pane. Return false to prevent. |
didClosePane |
Called after a pane is closed. |
didFocusPane |
Called when focus changes to a different pane. |
didChangeGeometry |
Called when any pane geometry changes (resize, split, close). |
shouldNotifyDuringDrag |
Return true for real-time notifications during divider drag. |
#Geometry Notifications
Receive callbacks when pane geometry changes:
func splitTabBar(_ controller: BonsplitController,
didChangeGeometry snapshot: LayoutSnapshot) {
// Save layout configuration
let encoder = JSONEncoder()
if let data = try? encoder.encode(snapshot) {
UserDefaults.standard.set(data, forKey: "savedLayout")
}
}
// Opt-in to real-time notifications during divider drag
func splitTabBar(_ controller: BonsplitController,
shouldNotifyDuringDrag: Bool) -> Bool {
return true // Enable frame-by-frame updates
}
#BonsplitConfiguration
Configure behavior and appearance.
let config = BonsplitConfiguration(
allowSplits: true, // Enable split buttons and drag-to-split
allowCloseTabs: true, // Show close buttons on tabs
allowCloseLastPane: false, // Prevent closing the last pane
allowTabReordering: true, // Enable drag-to-reorder
allowCrossPaneTabMove: true, // Enable moving tabs between panes
autoCloseEmptyPanes: true, // Close panes when last tab is closed
contentViewLifecycle: .recreateOnSwitch, // How tab views are managed
newTabPosition: .current, // Where new tabs are inserted
appearance: .default
)
let controller = BonsplitController(configuration: config)
#Content View Lifecycle
Controls how tab content views are managed when switching between tabs:
// Memory efficient (default) - only selected tab is rendered // Loses scroll position, @State, focus when switching tabs contentViewLifecycle: .recreateOnSwitch // macOS-like behavior - all tab views stay in memory // Preserves scroll position, @State, focus, text selection, etc. contentViewLifecycle: .keepAllAlive
| Mode | Memory | State Preservation | Use Case |
|---|---|---|---|
.recreateOnSwitch |
Low | None | Simple content, external state management |
.keepAllAlive |
Higher | Full | Complex views, scroll positions, form inputs |
#New Tab Position
Controls where new tabs are inserted in the tab list:
// Insert after currently focused tab (default) newTabPosition: .current // Always insert at the end of the tab list newTabPosition: .end
| Mode | Behavior |
|---|---|
.current |
Insert after the currently focused tab, or at the end if no tab is focused |
.end |
Always insert at the end of the tab list |
#Appearance Configuration
let appearance = BonsplitConfiguration.Appearance(
tabBarHeight: 33,
tabMinWidth: 140,
tabMaxWidth: 220,
tabSpacing: 0,
minimumPaneWidth: 100,
minimumPaneHeight: 100,
showSplitButtons: true,
animationDuration: 0.15,
enableAnimations: true
)
let config = BonsplitConfiguration(appearance: appearance)
#Configuration Presets
// Default configuration BonsplitConfiguration.default // Single pane mode (no splits) BonsplitConfiguration.singlePane // Read-only mode (no modifications) BonsplitConfiguration.readOnly
#Examples
#Preserving Tab State
Use .keepAllAlive to preserve scroll position, focus, and @State when switching tabs:
struct EditorApp: View {
@State private var controller: BonsplitController
init() {
let config = BonsplitConfiguration(
contentViewLifecycle: .keepAllAlive // Preserve state across tab switches
)
_controller = State(initialValue: BonsplitController(configuration: config))
}
var body: some View {
BonsplitView(controller: controller) { tab, paneId in
ScrollView {
// Scroll position is preserved when switching tabs!
LongDocumentView(tabId: tab.id)
}
}
}
}
With .keepAllAlive:
- Scroll positions are preserved
- Text selections remain intact
@Statevariables keep their values- Focus stays where you left it
- Form inputs don't reset
#Document Editor
struct DocumentEditorApp: View {
@State private var controller = BonsplitController()
@State private var documents: [TabID: Document] = [:]
@StateObject private var delegate = DocumentDelegate()
var body: some View {
BonsplitView(controller: controller) { tab in
if let doc = documents[tab.id] {
TextEditor(text: Binding(
get: { doc.content },
set: { newValue in
doc.content = newValue
controller.updateTab(tab.id, isDirty: true)
}
))
}
}
.onAppear {
controller.delegate = delegate
delegate.documents = $documents
delegate.controller = controller
newDocument()
}
.toolbar {
Button("New") { newDocument() }
Button("Save") { saveCurrentDocument() }
}
}
func newDocument() {
let doc = Document()
if let tabId = controller.createTab(title: doc.name, icon: "doc.text") {
documents[tabId] = doc
}
}
}
class DocumentDelegate: ObservableObject, BonsplitDelegate {
var documents: Binding<[TabID: Document]>?
weak var controller: BonsplitController?
func splitTabBar(_ controller: BonsplitController,
shouldCloseTab tab: Tab,
inPane pane: PaneID) -> Bool {
guard tab.isDirty else { return true }
let alert = NSAlert()
alert.messageText = "Save \(tab.title)?"
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Don't Save")
alert.addButton(withTitle: "Cancel")
switch alert.runModal() {
case .alertFirstButtonReturn:
// Save then close
return true
case .alertSecondButtonReturn:
// Close without saving
return true
default:
// Cancel
return false
}
}
func splitTabBar(_ controller: BonsplitController,
didCloseTab tabId: TabID,
fromPane pane: PaneID) {
documents?.wrappedValue.removeValue(forKey: tabId)
}
}
#Menu Commands
struct AppCommands: Commands {
@FocusedObject var controller: BonsplitController?
var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("New Tab") {
controller?.createTab(title: "Untitled", icon: "doc.text")
}
.keyboardShortcut("t", modifiers: .command)
Button("Close Tab") {
if let pane = controller?.focusedPaneId,
let tab = controller?.selectedTab(inPane: pane) {
controller?.closeTab(tab.id)
}
}
.keyboardShortcut("w", modifiers: .command)
}
CommandMenu("View") {
Button("Split Right") {
controller?.splitPane(orientation: .horizontal)
}
.keyboardShortcut("d", modifiers: [.command, .shift])
Button("Split Down") {
controller?.splitPane(orientation: .vertical)
}
.keyboardShortcut("d", modifiers: [.command, .option])
}
}
}
#Custom Empty Pane View
Customize what users see when a pane has no tabs:
struct MyApp: View {
@State private var controller = BonsplitController()
var body: some View {
BonsplitView(controller: controller) { tab in
TabContentView(tab: tab)
} emptyPane: { paneId in
// Fully customizable empty state
VStack(spacing: 20) {
Image(systemName: "doc.badge.plus")
.font(.system(size: 48))
.foregroundStyle(.tertiary)
Text("No Open Files")
.font(.title2)
HStack {
Button("New File") {
controller.createTab(title: "Untitled", icon: "doc", inPane: paneId)
}
.buttonStyle(.borderedProminent)
if controller.allPaneIds.count > 1 {
Button("Close Pane") {
controller.closePane(paneId)
}
}
}
}
}
}
}
If you don't provide an emptyPane builder, a default minimal view is shown.
#Auto-Create Tabs on Split
Use the delegate to automatically create a tab when a pane is split:
class MyDelegate: BonsplitDelegate {
func splitTabBar(_ controller: BonsplitController,
didSplitPane originalPane: PaneID,
newPane: PaneID,
orientation: SplitOrientation) {
// Automatically create a tab in the new pane
controller.createTab(title: "Untitled", icon: "doc.text", inPane: newPane)
}
}
#Custom Tab Content
enum TabContent {
case editor(Document)
case preview(URL)
case settings
}
struct MyApp: View {
@State private var controller = BonsplitController()
@State private var content: [TabID: TabContent] = [:]
var body: some View {
BonsplitView(controller: controller) { tab in
switch content[tab.id] {
case .editor(let doc):
DocumentEditor(document: doc)
case .preview(let url):
WebView(url: url)
case .settings:
SettingsView()
case .none:
EmptyView()
}
}
}
func openDocument(_ doc: Document) {
if let tabId = controller.createTab(title: doc.name, icon: "doc.text") {
content[tabId] = .editor(doc)
}
}
func openPreview(_ url: URL) {
if let tabId = controller.createTab(title: "Preview", icon: "globe") {
content[tabId] = .preview(url)
}
}
}
#License
MIT License