diff --git a/Example/BonsplitExample/AppState.swift b/Example/BonsplitExample/AppState.swift index 6c642ed..4e48d4e 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) @@ -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") 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/Controllers/SplitViewController.swift b/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift index f49a8c5..85ce56d 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,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, @@ -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, @@ -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( 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 + ) + } } } 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/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 5b3f5e8..05247ca 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) @@ -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) @@ -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) @@ -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 ) } 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/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 3ef0080..b9d5ccb 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -59,4 +59,163 @@ final class BonsplitTests: XCTestCase { XCTAssertFalse(controller.configuration.allowSplits) XCTAssertTrue(controller.configuration.allowCloseTabs) } + + // MARK: - Zoom + + @MainActor + func testZoomToggle() { + // Single pane — no-op + let singleController = BonsplitController() + XCTAssertFalse(singleController.toggleZoom()) + XCTAssertFalse(singleController.isZoomed) + + // Two panes — full toggle lifecycle + let (controller, paneA, paneB) = makeTwoPaneController() + XCTAssertFalse(controller.isZoomed) + + // Zoom on + XCTAssertTrue(controller.toggleZoom(paneId: paneA)) + XCTAssertTrue(controller.isZoomed) + XCTAssertEqual(controller.zoomedPaneId, paneA) + + // Toggle same pane — zoom off + XCTAssertTrue(controller.toggleZoom(paneId: paneA)) + XCTAssertFalse(controller.isZoomed) + + // Move zoom between panes + controller.toggleZoom(paneId: paneA) + XCTAssertTrue(controller.toggleZoom(paneId: paneB)) + XCTAssertEqual(controller.zoomedPaneId, paneB) + + // Defaults to focused pane + controller.unzoom() + controller.focusPane(paneA) + controller.toggleZoom() + XCTAssertEqual(controller.zoomedPaneId, paneA) + + // Explicit unzoom + controller.unzoom() + XCTAssertFalse(controller.isZoomed) + XCTAssertNil(controller.zoomedPaneId) + } + + @MainActor + func testZoomClearsOnStructuralChanges() { + // Split while zoomed → clears + let (c1, paneA1, _) = makeTwoPaneController() + c1.toggleZoom(paneId: paneA1) + c1.splitPane(paneA1, orientation: .horizontal) + XCTAssertFalse(c1.isZoomed) + + // Close zoomed pane → clears + let (c2, paneA2, _) = makeTwoPaneController() + c2.toggleZoom(paneId: paneA2) + c2.closePane(paneA2) + XCTAssertFalse(c2.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) + + // 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 testZoomSnapshots() { + let (controller, paneA, _) = 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) + let zoomed = controller.layoutSnapshot() + XCTAssertEqual(zoomed.panes.count, 1) + XCTAssertTrue(zoomed.isZoomed) + XCTAssertEqual(zoomed.zoomedPaneId, paneA.id.uuidString) + + // Tree snapshot always returns full tree + let tree = controller.treeSnapshot() + if case .split(let split) = tree { + XCTAssertNotNil(split) + } else { + XCTFail("Expected split node at root, got pane") + } + } + + @MainActor + func testZoomNavigation() { + // Default: navigate unzooms + let (c1, paneA1, _) = makeTwoPaneController() + c1.focusPane(paneA1) + c1.toggleZoom(paneId: paneA1) + c1.navigateFocus(direction: .right) + XCTAssertFalse(c1.isZoomed) + + // Preserve mode: navigate moves zoom + let config = BonsplitConfiguration(preserveZoomOnNavigation: true) + 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) + } + + @MainActor + func testZoomConfiguration() { + XCTAssertFalse(BonsplitConfiguration.default.preserveZoomOnNavigation) + XCTAssertTrue(BonsplitConfiguration(preserveZoomOnNavigation: true).preserveZoomOnNavigation) + } + + // 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! + // 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) + } + + @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! + // 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) + } }