From c054e841b2f722afe14545176734c8858ec14058 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 26 Feb 2026 18:11:51 -0300 Subject: [PATCH 1/9] Add zoomedPaneId state and toggle/unzoom to SplitViewController --- .../Controllers/SplitViewController.swift | 39 +++++++++++ Tests/BonsplitTests/BonsplitTests.swift | 67 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift b/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift index f49a8c5..6de967a 100644 --- a/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift +++ b/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift @@ -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 @@ -55,6 +58,42 @@ 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 diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 3ef0080..567a9d4 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -59,4 +59,71 @@ final class BonsplitTests: XCTestCase { XCTAssertFalse(controller.configuration.allowSplits) XCTAssertTrue(controller.configuration.allowCloseTabs) } + + // MARK: - Zoom Tests + + @MainActor + func testToggleZoomSinglePaneIsNoOp() { + let controller = BonsplitController() + let svc = controller.internalController + + let result = svc.toggleZoom(paneId: svc.focusedPaneId) + XCTAssertFalse(result) + XCTAssertNil(svc.zoomedPaneId) + } + + @MainActor + func testToggleZoomWithTwoPanes() { + let (controller, paneA, _) = makeTwoPaneController() + let svc = controller.internalController + + let result = svc.toggleZoom(paneId: paneA) + XCTAssertTrue(result) + XCTAssertEqual(svc.zoomedPaneId, paneA) + } + + @MainActor + func testToggleZoomSamePaneUnzooms() { + let (controller, paneA, _) = makeTwoPaneController() + let svc = controller.internalController + + svc.toggleZoom(paneId: paneA) + let result = svc.toggleZoom(paneId: paneA) + XCTAssertTrue(result) + XCTAssertNil(svc.zoomedPaneId) + } + + @MainActor + func testToggleZoomDifferentPaneMovesZoom() { + let (controller, paneA, paneB) = makeTwoPaneController() + let svc = controller.internalController + + svc.toggleZoom(paneId: paneA) + let result = svc.toggleZoom(paneId: paneB) + XCTAssertTrue(result) + XCTAssertEqual(svc.zoomedPaneId, paneB) + } + + @MainActor + func testUnzoomClearsState() { + let (controller, paneA, _) = makeTwoPaneController() + let svc = controller.internalController + + svc.toggleZoom(paneId: paneA) + XCTAssertNotNil(svc.zoomedPaneId) + svc.unzoom() + XCTAssertNil(svc.zoomedPaneId) + } + + // MARK: - Test Helpers + + @MainActor + private func makeTwoPaneController() -> (BonsplitController, PaneID, PaneID) { + let controller = BonsplitController() + let paneA = controller.focusedPaneId! + controller.splitPane(paneA, orientation: .horizontal) + // focus moves to new pane after split + let paneB = controller.focusedPaneId! + return (controller, paneA, paneB) + } } From 0928e9ff88d14108c5844725d852c5c2c2f8078c Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 26 Feb 2026 18:42:06 -0300 Subject: [PATCH 2/9] Add public zoom API and delegate callbacks to BonsplitController --- .../Bonsplit/Public/BonsplitController.swift | 40 +++++++++++++++++ .../Bonsplit/Public/BonsplitDelegate.swift | 10 +++++ Tests/BonsplitTests/BonsplitTests.swift | 45 +++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 5b3f5e8..b12e3e7 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -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) diff --git a/Sources/Bonsplit/Public/BonsplitDelegate.swift b/Sources/Bonsplit/Public/BonsplitDelegate.swift index 7dac50a..b7c0674 100644 --- a/Sources/Bonsplit/Public/BonsplitDelegate.swift +++ b/Sources/Bonsplit/Public/BonsplitDelegate.swift @@ -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. @@ -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 } diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 567a9d4..092c566 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -115,6 +115,51 @@ final class BonsplitTests: XCTestCase { XCTAssertNil(svc.zoomedPaneId) } + // MARK: - Zoom Public API Tests + + @MainActor + func testPublicToggleZoomReflectsState() { + let (controller, paneA, _) = makeTwoPaneController() + + let result = controller.toggleZoom(paneId: paneA) + XCTAssertTrue(result) + XCTAssertTrue(controller.isZoomed) + XCTAssertEqual(controller.zoomedPaneId, paneA) + } + + @MainActor + func testPublicToggleZoomDefaultsToFocusedPane() { + let (controller, _, paneB) = makeTwoPaneController() + // paneB is focused after split + XCTAssertEqual(controller.focusedPaneId, paneB) + + let result = controller.toggleZoom() + XCTAssertTrue(result) + XCTAssertEqual(controller.zoomedPaneId, paneB) + } + + @MainActor + func testPublicUnzoomClearsState() { + let (controller, paneA, _) = makeTwoPaneController() + + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.isZoomed) + controller.unzoom() + XCTAssertFalse(controller.isZoomed) + XCTAssertNil(controller.zoomedPaneId) + } + + @MainActor + func testIsZoomedReflectsState() { + let (controller, paneA, _) = makeTwoPaneController() + + XCTAssertFalse(controller.isZoomed) + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.isZoomed) + controller.toggleZoom(paneId: paneA) + XCTAssertFalse(controller.isZoomed) + } + // MARK: - Test Helpers @MainActor From 3848c5e15d083935acf4d47f08f4471858f49004 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 26 Feb 2026 18:49:28 -0300 Subject: [PATCH 3/9] Add preserveZoomOnNavigation to BonsplitConfiguration --- .../Bonsplit/Public/BonsplitConfiguration.swift | 6 ++++++ Tests/BonsplitTests/BonsplitTests.swift | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration.swift b/Sources/Bonsplit/Public/BonsplitConfiguration.swift index e08283d..5980360 100644 --- a/Sources/Bonsplit/Public/BonsplitConfiguration.swift +++ b/Sources/Bonsplit/Public/BonsplitConfiguration.swift @@ -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 @@ -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 @@ -95,6 +100,7 @@ public struct BonsplitConfiguration: Sendable { self.autoCloseEmptyPanes = autoCloseEmptyPanes self.contentViewLifecycle = contentViewLifecycle self.newTabPosition = newTabPosition + self.preserveZoomOnNavigation = preserveZoomOnNavigation self.appearance = appearance } } diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 092c566..77696ff 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -60,7 +60,7 @@ final class BonsplitTests: XCTestCase { XCTAssertTrue(controller.configuration.allowCloseTabs) } - // MARK: - Zoom Tests + // MARK: - Zoom @MainActor func testToggleZoomSinglePaneIsNoOp() { @@ -115,8 +115,6 @@ final class BonsplitTests: XCTestCase { XCTAssertNil(svc.zoomedPaneId) } - // MARK: - Zoom Public API Tests - @MainActor func testPublicToggleZoomReflectsState() { let (controller, paneA, _) = makeTwoPaneController() @@ -160,6 +158,18 @@ final class BonsplitTests: XCTestCase { XCTAssertFalse(controller.isZoomed) } + @MainActor + func testDefaultConfigPreserveZoomIsFalse() { + let config = BonsplitConfiguration.default + XCTAssertFalse(config.preserveZoomOnNavigation) + } + + @MainActor + func testConfigAcceptsPreserveZoomOnNavigation() { + let config = BonsplitConfiguration(preserveZoomOnNavigation: true) + XCTAssertTrue(config.preserveZoomOnNavigation) + } + // MARK: - Test Helpers @MainActor From 34850bd55372238e30333f236a26e4325266658d Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 26 Feb 2026 19:29:48 -0300 Subject: [PATCH 4/9] Conditionally render zoomed pane or full tree in SplitViewContainer --- Example/BonsplitExample/AppState.swift | 4 +++ .../BonsplitExample/BonsplitExampleApp.swift | 7 +++++ .../Internal/Views/SplitViewContainer.swift | 29 ++++++++++++++----- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/Example/BonsplitExample/AppState.swift b/Example/BonsplitExample/AppState.swift index 6c642ed..688174c 100644 --- a/Example/BonsplitExample/AppState.swift +++ b/Example/BonsplitExample/AppState.swift @@ -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) diff --git a/Example/BonsplitExample/BonsplitExampleApp.swift b/Example/BonsplitExample/BonsplitExampleApp.swift index 367ca2a..482c6f0 100644 --- a/Example/BonsplitExample/BonsplitExampleApp.swift +++ b/Example/BonsplitExample/BonsplitExampleApp.swift @@ -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() diff --git a/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift b/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift index 1896297..aa52f5d 100644 --- a/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift +++ b/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift @@ -34,13 +34,26 @@ struct SplitViewContainer: 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 + ) + } } } From d32a1e6c0b6e43757612a91ee0aa5cfe6bd537ac Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 26 Feb 2026 19:34:54 -0300 Subject: [PATCH 5/9] Clear zoom state on split/close operations per behavior rules --- .../Controllers/SplitViewController.swift | 9 +++ Tests/BonsplitTests/BonsplitTests.swift | 60 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift b/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift index 6de967a..85ce56d 100644 --- a/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift +++ b/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift @@ -98,6 +98,7 @@ final class SplitViewController { /// 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, @@ -158,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, @@ -245,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( diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 77696ff..447ebbb 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -170,8 +170,68 @@ final class BonsplitTests: XCTestCase { XCTAssertTrue(config.preserveZoomOnNavigation) } + @MainActor + func testSplitWhileZoomedClearsZoom() { + let (controller, paneA, _) = makeTwoPaneController() + + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.isZoomed) + + controller.splitPane(paneA, orientation: .horizontal) + XCTAssertFalse(controller.isZoomed) + } + + @MainActor + func testCloseZoomedPaneClearsZoom() { + let (controller, paneA, _) = makeTwoPaneController() + + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.isZoomed) + + controller.closePane(paneA) + XCTAssertFalse(controller.isZoomed) + XCTAssertNil(controller.zoomedPaneId) + } + + @MainActor + func testCloseSiblingPreservesZoom() { + let (controller, paneA, paneB) = makeThreePaneController() + + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.isZoomed) + + // Close a sibling — zoom should survive + controller.closePane(paneB) + XCTAssertTrue(controller.isZoomed) + XCTAssertEqual(controller.zoomedPaneId, paneA) + } + + @MainActor + func testCloseSiblingCollapseClearsZoom() { + let (controller, paneA, paneB) = makeTwoPaneController() + + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.isZoomed) + + // Close the only sibling — tree collapses to single pane + controller.closePane(paneB) + XCTAssertFalse(controller.isZoomed) + } + // MARK: - Test Helpers + @MainActor + private func makeThreePaneController() -> (BonsplitController, PaneID, PaneID) { + let controller = BonsplitController() + let paneA = controller.focusedPaneId! + controller.splitPane(paneA, orientation: .horizontal) + let paneB = controller.focusedPaneId! + // Split paneB to get a third pane — paneA is still intact + controller.splitPane(paneB, orientation: .horizontal) + // Returns paneA and paneB (paneC exists but we don't need its ID) + return (controller, paneA, paneB) + } + @MainActor private func makeTwoPaneController() -> (BonsplitController, PaneID, PaneID) { let controller = BonsplitController() From 4269917fd6c065148773c60b70ee2d6e2b328272 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 26 Feb 2026 19:45:38 -0300 Subject: [PATCH 6/9] Handle focus navigation while zoomed based on configuration --- .../Bonsplit/Public/BonsplitController.swift | 18 ++++++++ Tests/BonsplitTests/BonsplitTests.swift | 46 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index b12e3e7..0e7ad10 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -309,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) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 447ebbb..d082c62 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -218,6 +218,39 @@ final class BonsplitTests: XCTestCase { XCTAssertFalse(controller.isZoomed) } + @MainActor + func testNavigateWhileZoomedUnzooms() { + let (controller, paneA, _) = makeTwoPaneController() + // Focus paneA and zoom it + controller.focusPane(paneA) + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.isZoomed) + + controller.navigateFocus(direction: .right) + XCTAssertFalse(controller.isZoomed) + } + + @MainActor + func testNavigateWhileZoomedPreservesZoom() { + let config = BonsplitConfiguration(preserveZoomOnNavigation: true) + let controller = BonsplitController(configuration: config) + let paneA = controller.focusedPaneId! + controller.splitPane(paneA, orientation: .horizontal) + let paneB = controller.focusedPaneId! + // Fix divider so both panes have real bounds + if let split = controller.internalController.allSplits.first { + split.dividerPosition = 0.5 + } + + controller.focusPane(paneA) + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.isZoomed) + + controller.navigateFocus(direction: .right) + XCTAssertTrue(controller.isZoomed) + XCTAssertEqual(controller.zoomedPaneId, paneB) + } + // MARK: - Test Helpers @MainActor @@ -226,8 +259,15 @@ final class BonsplitTests: XCTestCase { let paneA = controller.focusedPaneId! controller.splitPane(paneA, orientation: .horizontal) let paneB = controller.focusedPaneId! + // Fix divider position for geometry calculations + if let split = controller.internalController.allSplits.first { + split.dividerPosition = 0.5 + } // Split paneB to get a third pane — paneA is still intact controller.splitPane(paneB, orientation: .horizontal) + if let splits = controller.internalController.allSplits.last { + splits.dividerPosition = 0.5 + } // Returns paneA and paneB (paneC exists but we don't need its ID) return (controller, paneA, paneB) } @@ -238,7 +278,11 @@ final class BonsplitTests: XCTestCase { let paneA = controller.focusedPaneId! controller.splitPane(paneA, orientation: .horizontal) // focus moves to new pane after split - let paneB = controller.focusedPaneId! + let paneB = controller.focusedPaneId! + // Fix divider position (starts at 1.0 for animation, set to 0.5 for test geometry) + if let split = controller.internalController.allSplits.first { + split.dividerPosition = 0.5 + } return (controller, paneA, paneB) } } From 3501568e0e2ca1bc8b4ec264633587840fbae77b Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 26 Feb 2026 20:03:58 -0300 Subject: [PATCH 7/9] Include zoom state in layout and tree snapshots --- .../Bonsplit/Public/BonsplitController.swift | 12 +++++- .../Public/Types/LayoutSnapshot.swift | 6 ++- Tests/BonsplitTests/BonsplitTests.swift | 38 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 0e7ad10..05247ca 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -375,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) @@ -397,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 ) } diff --git a/Sources/Bonsplit/Public/Types/LayoutSnapshot.swift b/Sources/Bonsplit/Public/Types/LayoutSnapshot.swift index 5027ea8..a6b8caa 100644 --- a/Sources/Bonsplit/Public/Types/LayoutSnapshot.swift +++ b/Sources/Bonsplit/Public/Types/LayoutSnapshot.swift @@ -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 } } diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index d082c62..5a54743 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -218,6 +218,44 @@ final class BonsplitTests: XCTestCase { XCTAssertFalse(controller.isZoomed) } + @MainActor + func testLayoutSnapshotWhileZoomed() { + let (controller, paneA, _) = makeTwoPaneController() + + controller.toggleZoom(paneId: paneA) + let snapshot = controller.layoutSnapshot() + + XCTAssertEqual(snapshot.panes.count, 1) + XCTAssertTrue(snapshot.isZoomed) + XCTAssertEqual(snapshot.zoomedPaneId, paneA.id.uuidString) + } + + @MainActor + func testLayoutSnapshotNotZoomed() { + let (controller, _, _) = makeTwoPaneController() + + let snapshot = controller.layoutSnapshot() + + XCTAssertEqual(snapshot.panes.count, 2) + XCTAssertFalse(snapshot.isZoomed) + XCTAssertNil(snapshot.zoomedPaneId) + } + + @MainActor + func testTreeSnapshotWhileZoomedReturnsFull() { + let (controller, paneA, _) = makeTwoPaneController() + + controller.toggleZoom(paneId: paneA) + let tree = controller.treeSnapshot() + + // Tree should contain both panes regardless of zoom + if case .split(let split) = tree { + XCTAssertNotNil(split) + } else { + XCTFail("Expected split node at root, got pane") + } + } + @MainActor func testNavigateWhileZoomedUnzooms() { let (controller, paneA, _) = makeTwoPaneController() From ae74cfdc6ed13ccaab1d0badf76e1510216eedeb Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 26 Feb 2026 20:08:46 -0300 Subject: [PATCH 8/9] Add delegate loggin on Example app --- Example/BonsplitExample/AppState.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Example/BonsplitExample/AppState.swift b/Example/BonsplitExample/AppState.swift index 688174c..4e48d4e 100644 --- a/Example/BonsplitExample/AppState.swift +++ b/Example/BonsplitExample/AppState.swift @@ -161,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") From aebe0be20367998de5e892eae3de676ec6d60cb2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 26 Feb 2026 20:11:35 -0300 Subject: [PATCH 9/9] Reduce amount of tests --- Tests/BonsplitTests/BonsplitTests.swift | 263 +++++++----------------- 1 file changed, 79 insertions(+), 184 deletions(-) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 5a54743..b9d5ccb 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -60,195 +60,92 @@ final class BonsplitTests: XCTestCase { XCTAssertTrue(controller.configuration.allowCloseTabs) } - // MARK: - Zoom + // MARK: - Zoom @MainActor - func testToggleZoomSinglePaneIsNoOp() { - let controller = BonsplitController() - let svc = controller.internalController - - let result = svc.toggleZoom(paneId: svc.focusedPaneId) - XCTAssertFalse(result) - XCTAssertNil(svc.zoomedPaneId) - } - - @MainActor - func testToggleZoomWithTwoPanes() { - let (controller, paneA, _) = makeTwoPaneController() - let svc = controller.internalController - - let result = svc.toggleZoom(paneId: paneA) - XCTAssertTrue(result) - XCTAssertEqual(svc.zoomedPaneId, paneA) - } + func testZoomToggle() { + // Single pane — no-op + let singleController = BonsplitController() + XCTAssertFalse(singleController.toggleZoom()) + XCTAssertFalse(singleController.isZoomed) - @MainActor - func testToggleZoomSamePaneUnzooms() { - let (controller, paneA, _) = makeTwoPaneController() - let svc = controller.internalController - - svc.toggleZoom(paneId: paneA) - let result = svc.toggleZoom(paneId: paneA) - XCTAssertTrue(result) - XCTAssertNil(svc.zoomedPaneId) - } - - @MainActor - func testToggleZoomDifferentPaneMovesZoom() { + // Two panes — full toggle lifecycle let (controller, paneA, paneB) = makeTwoPaneController() - let svc = controller.internalController - - svc.toggleZoom(paneId: paneA) - let result = svc.toggleZoom(paneId: paneB) - XCTAssertTrue(result) - XCTAssertEqual(svc.zoomedPaneId, paneB) - } - - @MainActor - func testUnzoomClearsState() { - let (controller, paneA, _) = makeTwoPaneController() - let svc = controller.internalController - - svc.toggleZoom(paneId: paneA) - XCTAssertNotNil(svc.zoomedPaneId) - svc.unzoom() - XCTAssertNil(svc.zoomedPaneId) - } - - @MainActor - func testPublicToggleZoomReflectsState() { - let (controller, paneA, _) = makeTwoPaneController() + XCTAssertFalse(controller.isZoomed) - let result = controller.toggleZoom(paneId: paneA) - XCTAssertTrue(result) + // Zoom on + XCTAssertTrue(controller.toggleZoom(paneId: paneA)) XCTAssertTrue(controller.isZoomed) XCTAssertEqual(controller.zoomedPaneId, paneA) - } - @MainActor - func testPublicToggleZoomDefaultsToFocusedPane() { - let (controller, _, paneB) = makeTwoPaneController() - // paneB is focused after split - XCTAssertEqual(controller.focusedPaneId, paneB) + // Toggle same pane — zoom off + XCTAssertTrue(controller.toggleZoom(paneId: paneA)) + XCTAssertFalse(controller.isZoomed) - let result = controller.toggleZoom() - XCTAssertTrue(result) + // Move zoom between panes + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.toggleZoom(paneId: paneB)) XCTAssertEqual(controller.zoomedPaneId, paneB) - } - @MainActor - func testPublicUnzoomClearsState() { - let (controller, paneA, _) = makeTwoPaneController() + // Defaults to focused pane + controller.unzoom() + controller.focusPane(paneA) + controller.toggleZoom() + XCTAssertEqual(controller.zoomedPaneId, paneA) - controller.toggleZoom(paneId: paneA) - XCTAssertTrue(controller.isZoomed) + // Explicit unzoom controller.unzoom() XCTAssertFalse(controller.isZoomed) XCTAssertNil(controller.zoomedPaneId) } @MainActor - func testIsZoomedReflectsState() { - let (controller, paneA, _) = makeTwoPaneController() + func testZoomClearsOnStructuralChanges() { + // Split while zoomed → clears + let (c1, paneA1, _) = makeTwoPaneController() + c1.toggleZoom(paneId: paneA1) + c1.splitPane(paneA1, orientation: .horizontal) + XCTAssertFalse(c1.isZoomed) - XCTAssertFalse(controller.isZoomed) - controller.toggleZoom(paneId: paneA) - XCTAssertTrue(controller.isZoomed) - controller.toggleZoom(paneId: paneA) - XCTAssertFalse(controller.isZoomed) - } + // Close zoomed pane → clears + let (c2, paneA2, _) = makeTwoPaneController() + c2.toggleZoom(paneId: paneA2) + c2.closePane(paneA2) + XCTAssertFalse(c2.isZoomed) - @MainActor - func testDefaultConfigPreserveZoomIsFalse() { - let config = BonsplitConfiguration.default - XCTAssertFalse(config.preserveZoomOnNavigation) - } - - @MainActor - func testConfigAcceptsPreserveZoomOnNavigation() { - let config = BonsplitConfiguration(preserveZoomOnNavigation: true) - XCTAssertTrue(config.preserveZoomOnNavigation) - } - - @MainActor - func testSplitWhileZoomedClearsZoom() { - let (controller, paneA, _) = makeTwoPaneController() - - controller.toggleZoom(paneId: paneA) - XCTAssertTrue(controller.isZoomed) + // Close sibling (3 panes) → preserves zoom + let (c3, paneA3, paneB3) = makeThreePaneController() + c3.toggleZoom(paneId: paneA3) + c3.closePane(paneB3) + XCTAssertTrue(c3.isZoomed) + XCTAssertEqual(c3.zoomedPaneId, paneA3) - controller.splitPane(paneA, orientation: .horizontal) - XCTAssertFalse(controller.isZoomed) + // Close only sibling (collapse to 1 pane) → clears + let (c4, paneA4, paneB4) = makeTwoPaneController() + c4.toggleZoom(paneId: paneA4) + c4.closePane(paneB4) + XCTAssertFalse(c4.isZoomed) } @MainActor - func testCloseZoomedPaneClearsZoom() { + func testZoomSnapshots() { let (controller, paneA, _) = makeTwoPaneController() - controller.toggleZoom(paneId: paneA) - XCTAssertTrue(controller.isZoomed) - - controller.closePane(paneA) - XCTAssertFalse(controller.isZoomed) - XCTAssertNil(controller.zoomedPaneId) - } - - @MainActor - func testCloseSiblingPreservesZoom() { - let (controller, paneA, paneB) = makeThreePaneController() - - controller.toggleZoom(paneId: paneA) - XCTAssertTrue(controller.isZoomed) - - // Close a sibling — zoom should survive - controller.closePane(paneB) - XCTAssertTrue(controller.isZoomed) - XCTAssertEqual(controller.zoomedPaneId, paneA) - } - - @MainActor - func testCloseSiblingCollapseClearsZoom() { - let (controller, paneA, paneB) = makeTwoPaneController() + // Not zoomed — all panes in layout + let normal = controller.layoutSnapshot() + XCTAssertEqual(normal.panes.count, 2) + XCTAssertFalse(normal.isZoomed) + XCTAssertNil(normal.zoomedPaneId) + // Zoomed — single pane in layout, full tree preserved controller.toggleZoom(paneId: paneA) - XCTAssertTrue(controller.isZoomed) - - // Close the only sibling — tree collapses to single pane - controller.closePane(paneB) - XCTAssertFalse(controller.isZoomed) - } - - @MainActor - func testLayoutSnapshotWhileZoomed() { - let (controller, paneA, _) = makeTwoPaneController() - - controller.toggleZoom(paneId: paneA) - let snapshot = controller.layoutSnapshot() - - XCTAssertEqual(snapshot.panes.count, 1) - XCTAssertTrue(snapshot.isZoomed) - XCTAssertEqual(snapshot.zoomedPaneId, paneA.id.uuidString) - } - - @MainActor - func testLayoutSnapshotNotZoomed() { - let (controller, _, _) = makeTwoPaneController() + let zoomed = controller.layoutSnapshot() + XCTAssertEqual(zoomed.panes.count, 1) + XCTAssertTrue(zoomed.isZoomed) + XCTAssertEqual(zoomed.zoomedPaneId, paneA.id.uuidString) - let snapshot = controller.layoutSnapshot() - - XCTAssertEqual(snapshot.panes.count, 2) - XCTAssertFalse(snapshot.isZoomed) - XCTAssertNil(snapshot.zoomedPaneId) - } - - @MainActor - func testTreeSnapshotWhileZoomedReturnsFull() { - let (controller, paneA, _) = makeTwoPaneController() - - controller.toggleZoom(paneId: paneA) + // Tree snapshot always returns full tree let tree = controller.treeSnapshot() - - // Tree should contain both panes regardless of zoom if case .split(let split) = tree { XCTAssertNotNil(split) } else { @@ -257,36 +154,34 @@ final class BonsplitTests: XCTestCase { } @MainActor - func testNavigateWhileZoomedUnzooms() { - let (controller, paneA, _) = makeTwoPaneController() - // Focus paneA and zoom it - controller.focusPane(paneA) - controller.toggleZoom(paneId: paneA) - XCTAssertTrue(controller.isZoomed) - - controller.navigateFocus(direction: .right) - XCTAssertFalse(controller.isZoomed) - } + func testZoomNavigation() { + // Default: navigate unzooms + let (c1, paneA1, _) = makeTwoPaneController() + c1.focusPane(paneA1) + c1.toggleZoom(paneId: paneA1) + c1.navigateFocus(direction: .right) + XCTAssertFalse(c1.isZoomed) - @MainActor - func testNavigateWhileZoomedPreservesZoom() { + // Preserve mode: navigate moves zoom let config = BonsplitConfiguration(preserveZoomOnNavigation: true) - let controller = BonsplitController(configuration: config) - let paneA = controller.focusedPaneId! - controller.splitPane(paneA, orientation: .horizontal) - let paneB = controller.focusedPaneId! - // Fix divider so both panes have real bounds - if let split = controller.internalController.allSplits.first { + let c2 = BonsplitController(configuration: config) + let paneA2 = c2.focusedPaneId! + c2.splitPane(paneA2, orientation: .horizontal) + let paneB2 = c2.focusedPaneId! + if let split = c2.internalController.allSplits.first { split.dividerPosition = 0.5 } + c2.focusPane(paneA2) + c2.toggleZoom(paneId: paneA2) + c2.navigateFocus(direction: .right) + XCTAssertTrue(c2.isZoomed) + XCTAssertEqual(c2.zoomedPaneId, paneB2) + } - controller.focusPane(paneA) - controller.toggleZoom(paneId: paneA) - XCTAssertTrue(controller.isZoomed) - - controller.navigateFocus(direction: .right) - XCTAssertTrue(controller.isZoomed) - XCTAssertEqual(controller.zoomedPaneId, paneB) + @MainActor + func testZoomConfiguration() { + XCTAssertFalse(BonsplitConfiguration.default.preserveZoomOnNavigation) + XCTAssertTrue(BonsplitConfiguration(preserveZoomOnNavigation: true).preserveZoomOnNavigation) } // MARK: - Test Helpers