Skip to content
16 changes: 16 additions & 0 deletions Example/BonsplitExample/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ class AppState: ObservableObject {
_ = controller.closeTab(tab.id)
}

func toggleZoom() {
controller.toggleZoom()
}

func splitHorizontal() {
// Split creates empty pane - we create a tab via the delegate callback
_ = controller.splitPane(orientation: .horizontal)
Expand Down Expand Up @@ -157,6 +161,18 @@ extension AppState: BonsplitDelegate {
// (The emptyPane view will be shown - see ContentView)
}

func splitTabBar(_ controller: BonsplitController,
didZoomPane paneId: PaneID) {
debugState?.log("🔍 didZoomPane: pane \(paneId.hashValue)")
debugState?.refresh()
}

func splitTabBar(_ controller: BonsplitController,
didUnzoomPane paneId: PaneID) {
debugState?.log("🔍 didUnzoomPane: pane \(paneId.hashValue)")
debugState?.refresh()
}

func splitTabBar(_ controller: BonsplitController,
didChangeGeometry snapshot: LayoutSnapshot) {
debugState?.log("Geometry changed: \(snapshot.panes.count) panes")
Expand Down
7 changes: 7 additions & 0 deletions Example/BonsplitExample/BonsplitExampleApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ struct AppCommands: Commands {
.keyboardShortcut("]", modifiers: [.command, .shift])
}

CommandMenu("View") {
Button("Toggle Zoom") {
appState?.toggleZoom()
}
.keyboardShortcut("z", modifiers: [.command, .shift])
}

CommandMenu("Split") {
Button("Split Right") {
appState?.splitHorizontal()
Expand Down
48 changes: 48 additions & 0 deletions Sources/Bonsplit/Internal/Controllers/SplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ final class SplitViewController {
/// Callback for geometry changes
var onGeometryChange: (() -> Void)?

/// Currently zoomed pane ID (nil = not zoomed)
var zoomedPaneId: PaneID?

init(rootNode: SplitNode? = nil) {
if let rootNode {
self.rootNode = rootNode
Expand All @@ -55,10 +58,47 @@ final class SplitViewController {
return rootNode.findPane(focusedPaneId)
}

// MARK: - Zoom

/// Toggle zoom on the specified pane (or the focused pane if nil).
/// - Returns: `true` if the zoom state changed
@discardableResult
func toggleZoom(paneId: PaneID? = nil) -> Bool {
let targetId = paneId ?? focusedPaneId
guard let targetId else { return false }

// If already zoomed
if let currentZoomed = zoomedPaneId {
if currentZoomed == targetId {
// Toggle off
zoomedPaneId = nil
return true
} else {
// Move zoom to different pane
guard rootNode.findPane(targetId) != nil else { return false }
zoomedPaneId = targetId
return true
}
}

// Not zoomed — only zoom if there are multiple panes
guard rootNode.allPaneIds.count > 1 else { return false }
guard rootNode.findPane(targetId) != nil else { return false }

zoomedPaneId = targetId
return true
}

/// Explicitly exit zoom without toggling.
func unzoom() {
zoomedPaneId = nil
}

// MARK: - Split Operations

/// Split the specified pane in the given orientation
func splitPane(_ paneId: PaneID, orientation: SplitOrientation, with newTab: TabItem? = nil) {
zoomedPaneId = nil
rootNode = splitNodeRecursively(
node: rootNode,
targetPaneId: paneId,
Expand Down Expand Up @@ -119,6 +159,7 @@ final class SplitViewController {

/// Split a pane with a specific tab, optionally inserting the new pane first
func splitPaneWithTab(_ paneId: PaneID, orientation: SplitOrientation, tab: TabItem, insertFirst: Bool) {
zoomedPaneId = nil
rootNode = splitNodeWithTabRecursively(
node: rootNode,
targetPaneId: paneId,
Expand Down Expand Up @@ -206,6 +247,13 @@ final class SplitViewController {
} else if let firstPane = rootNode.allPaneIds.first {
focusedPaneId = firstPane
}

// Clear zoom if zoomed pane was closed or tree collapsed to single pane
if let zid = zoomedPaneId {
if rootNode.findPane(zid) == nil || rootNode.allPaneIds.count <= 1 {
zoomedPaneId = nil
}
}
}

private func closePaneRecursively(
Expand Down
29 changes: 21 additions & 8 deletions Sources/Bonsplit/Internal/Views/SplitViewContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,26 @@ struct SplitViewContainer<Content: View, EmptyContent: View>: View {

@ViewBuilder
private var splitNodeContent: some View {
SplitNodeView(
node: controller.rootNode,
contentBuilder: contentBuilder,
emptyPaneBuilder: emptyPaneBuilder,
showSplitButtons: showSplitButtons,
contentViewLifecycle: contentViewLifecycle,
onGeometryChange: onGeometryChange
)
if let zoomedPaneId = controller.zoomedPaneId,
let zoomedPane = controller.rootNode.findPane(zoomedPaneId) {
// Render ONLY the zoomed pane — siblings absent from view hierarchy
SinglePaneWrapper(
pane: zoomedPane,
contentBuilder: contentBuilder,
emptyPaneBuilder: emptyPaneBuilder,
showSplitButtons: showSplitButtons,
contentViewLifecycle: contentViewLifecycle
)
} else {
// Normal: render the full tree from root
SplitNodeView(
node: controller.rootNode,
contentBuilder: contentBuilder,
emptyPaneBuilder: emptyPaneBuilder,
showSplitButtons: showSplitButtons,
contentViewLifecycle: contentViewLifecycle,
onGeometryChange: onGeometryChange
)
}
}
}
6 changes: 6 additions & 0 deletions Sources/Bonsplit/Public/BonsplitConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ public struct BonsplitConfiguration: Sendable {
/// Controls where new tabs are inserted in the tab list
public var newTabPosition: NewTabPosition

/// Whether focus navigation while zoomed preserves zoom (moves to target pane)
/// or exits zoom first. Default: `false` (unzoom on navigate).
public var preserveZoomOnNavigation: Bool

// MARK: - Appearance

/// Tab bar appearance customization
Expand Down Expand Up @@ -85,6 +89,7 @@ public struct BonsplitConfiguration: Sendable {
autoCloseEmptyPanes: Bool = true,
contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch,
newTabPosition: NewTabPosition = .current,
preserveZoomOnNavigation: Bool = false,
appearance: Appearance = .default
) {
self.allowSplits = allowSplits
Expand All @@ -95,6 +100,7 @@ public struct BonsplitConfiguration: Sendable {
self.autoCloseEmptyPanes = autoCloseEmptyPanes
self.contentViewLifecycle = contentViewLifecycle
self.newTabPosition = newTabPosition
self.preserveZoomOnNavigation = preserveZoomOnNavigation
self.appearance = appearance
}
}
Expand Down
70 changes: 69 additions & 1 deletion Sources/Bonsplit/Public/BonsplitController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,46 @@ public final class BonsplitController {
notifyTabSelection()
}

// MARK: - Zoom

/// The currently zoomed pane, if any. Only one pane can be zoomed at a time.
public var zoomedPaneId: PaneID? {
internalController.zoomedPaneId
}

/// Whether any pane is currently zoomed.
public var isZoomed: Bool {
zoomedPaneId != nil
}

/// Toggle zoom on the specified pane (or the focused pane if nil).
/// - Parameter paneId: The pane to zoom, or nil to use the focused pane
/// - Returns: `true` if the zoom state changed
@discardableResult
public func toggleZoom(paneId: PaneID? = nil) -> Bool {
let previousZoomed = internalController.zoomedPaneId
let zoomStateChanged = internalController.toggleZoom(paneId: paneId)

if zoomStateChanged {
if let zoomed = internalController.zoomedPaneId {
delegate?.splitTabBar(self, didZoomPane: zoomed)
} else if let previousZoomed {
delegate?.splitTabBar(self, didUnzoomPane: previousZoomed)
}
}

return zoomStateChanged
}

/// Explicitly exit zoom without toggling.
public func unzoom() {
let previousZoomed = internalController.zoomedPaneId
internalController.unzoom()
if let previousZoomed {
delegate?.splitTabBar(self, didUnzoomPane: previousZoomed)
}
}

// MARK: - Split Operations

/// Split the focused pane (or specified pane)
Expand Down Expand Up @@ -269,6 +309,24 @@ public final class BonsplitController {

/// Navigate focus in a direction
public func navigateFocus(direction: NavigationDirection) {
if internalController.zoomedPaneId != nil {
if configuration.preserveZoomOnNavigation {
// Navigate, then move zoom to the new focused pane
internalController.navigateFocus(direction: direction)
internalController.zoomedPaneId = internalController.focusedPaneId
} else {
// Unzoom first, then navigate
let previousZoomedId = internalController.zoomedPaneId!
internalController.zoomedPaneId = nil
delegate?.splitTabBar(self, didUnzoomPane: previousZoomedId)
internalController.navigateFocus(direction: direction)
}
if let focusedPaneId {
delegate?.splitTabBar(self, didFocusPane: focusedPaneId)
}
return
}

internalController.navigateFocus(direction: direction)
if let focusedPaneId {
delegate?.splitTabBar(self, didFocusPane: focusedPaneId)
Expand Down Expand Up @@ -317,7 +375,15 @@ public final class BonsplitController {
/// Get current layout snapshot with pixel coordinates
public func layoutSnapshot() -> LayoutSnapshot {
let containerFrame = internalController.containerFrame
let paneBounds = internalController.rootNode.computePaneBounds()
let zoomed = internalController.zoomedPaneId

// When zoomed, return only the zoomed pane filling the container
let paneBounds: [PaneBounds]
if let zoomed, let zoomedPane = internalController.rootNode.findPane(zoomed) {
paneBounds = [PaneBounds(paneId: zoomedPane.id, bounds: CGRect(x: 0, y: 0, width: 1, height: 1))]
} else {
paneBounds = internalController.rootNode.computePaneBounds()
}

let paneGeometries = paneBounds.map { bounds -> PaneGeometry in
let pane = internalController.rootNode.findPane(bounds.paneId)
Expand All @@ -339,6 +405,8 @@ public final class BonsplitController {
containerFrame: PixelRect(from: containerFrame),
panes: paneGeometries,
focusedPaneId: focusedPaneId?.id.uuidString,
isZoomed: zoomed != nil,
zoomedPaneId: zoomed?.id.uuidString,
timestamp: Date().timeIntervalSince1970
)
}
Expand Down
10 changes: 10 additions & 0 deletions Sources/Bonsplit/Public/BonsplitDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ public protocol BonsplitDelegate: AnyObject {
/// Called after a pane has been closed.
func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID)

// MARK: - Zoom (Notifications)

/// Called after a pane is zoomed to fill the container.
func splitTabBar(_ controller: BonsplitController, didZoomPane paneId: PaneID)

/// Called after zoom is exited and the full layout is restored.
func splitTabBar(_ controller: BonsplitController, didUnzoomPane paneId: PaneID)

// MARK: - Focus

/// Called when focus changes to a different pane.
Expand Down Expand Up @@ -71,6 +79,8 @@ public extension BonsplitDelegate {
func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { true }
func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) {}
func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) {}
func splitTabBar(_ controller: BonsplitController, didZoomPane paneId: PaneID) {}
func splitTabBar(_ controller: BonsplitController, didUnzoomPane paneId: PaneID) {}
func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) {}
func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) {}
func splitTabBar(_ controller: BonsplitController, shouldNotifyDuringDrag: Bool) -> Bool { false }
Expand Down
6 changes: 5 additions & 1 deletion Sources/Bonsplit/Public/Types/LayoutSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,16 @@ public struct LayoutSnapshot: Codable, Sendable, Equatable {
public let containerFrame: PixelRect
public let panes: [PaneGeometry]
public let focusedPaneId: String?
public let isZoomed: Bool
public let zoomedPaneId: String?
public let timestamp: TimeInterval

public init(containerFrame: PixelRect, panes: [PaneGeometry], focusedPaneId: String?, timestamp: TimeInterval) {
public init(containerFrame: PixelRect, panes: [PaneGeometry], focusedPaneId: String?, isZoomed: Bool = false, zoomedPaneId: String? = nil, timestamp: TimeInterval) {
self.containerFrame = containerFrame
self.panes = panes
self.focusedPaneId = focusedPaneId
self.isZoomed = isZoomed
self.zoomedPaneId = zoomedPaneId
self.timestamp = timestamp
}
}
Expand Down
Loading