SkillAgentSearch skills...

Bonsplit

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.

Install / Use

/learn @almonk/Bonsplit
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

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 |

Related Skills

View on GitHub
GitHub Stars370
CategoryCustomer
Updated2d ago
Forks44

Languages

TypeScript

Security Score

95/100

Audited on Mar 28, 2026

No findings