Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Bonsplit is a custom tab bar and layout split library for macOS apps. Out of the box 120fps animations, drag-and-drop reordering, SwiftUI support & keyboard navigation.

License

Notifications You must be signed in to change notification settings

almonk/bonsplit

Repository files navigation

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
  • @State variables 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

About

Bonsplit is a custom tab bar and layout split library for macOS apps. Out of the box 120fps animations, drag-and-drop reordering, SwiftUI support & keyboard navigation.

Resources

License

Stars

Watchers

Forks

Contributors 2

AltStyle によって変換されたページ (->オリジナル) /