From 43d7c0f78f6a9ec5dfca1ea423fb8f4c0d50bfd9 Mon Sep 17 00:00:00 2001 From: lsoengas Date: Fri, 10 Jul 2026 13:11:05 -0300 Subject: [PATCH 1/2] chore: vendor bonsplit in-tree, replacing the submodule vendor/bonsplit was a git submodule pointing at manaflow-ai/bonsplit, where we have no push access, so we could not land fixes to it. Per team decision, bring the library into the repo as regular source (MIT license permits this) so bonsplit changes become normal commits and PRs. ghostty stays a submodule fork. Vendored verbatim from manaflow-ai/bonsplit at b2788b1e77d43f0c114dcf189aa59cae8abb47de, omitting the Example app, the www site, and upstream CI config. Xcode already consumes it as a local path package (XCLocalSwiftPackageReference), so no build-system changes are needed. Docs and slash commands updated to drop bonsplit from submodule workflows. --- .claude/commands/pull.md | 2 +- .claude/commands/sync-branch.md | 4 +- .gitmodules | 3 - CLAUDE.md | 2 +- THIRD_PARTY_LICENSES.md | 1 + vendor/bonsplit | 1 - vendor/bonsplit/.gitignore | 59 + vendor/bonsplit/CHANGELOG.md | 57 + vendor/bonsplit/LICENSE | 21 + vendor/bonsplit/Package.swift | 32 + vendor/bonsplit/README.md | 623 ++++++++ .../Controllers/SplitViewController.swift | 519 +++++++ .../Bonsplit/Internal/Models/PaneState.swift | 108 ++ .../Bonsplit/Internal/Models/SplitNode.swift | 112 ++ .../Bonsplit/Internal/Models/SplitState.swift | 43 + .../Bonsplit/Internal/Models/TabItem.swift | 151 ++ .../Internal/Styling/TabBarColors.swift | 276 ++++ .../Internal/Styling/TabBarMetrics.swift | 53 + .../Internal/Utilities/SplitAnimator.swift | 140 ++ .../Internal/Views/PaneContainerView.swift | 577 +++++++ .../Internal/Views/SplitContainerView.swift | 907 +++++++++++ .../Internal/Views/SplitNodeView.swift | 121 ++ .../Internal/Views/SplitViewContainer.swift | 54 + .../Bonsplit/Internal/Views/TabBarView.swift | 1325 +++++++++++++++++ .../Internal/Views/TabDragPreview.swift | 30 + .../Bonsplit/Internal/Views/TabItemView.swift | 638 ++++++++ .../Public/BonsplitConfiguration.swift | 243 +++ .../Bonsplit/Public/BonsplitController.swift | 816 ++++++++++ .../Public/BonsplitDebugCounters.swift | 19 + .../Bonsplit/Public/BonsplitDelegate.swift | 88 ++ .../Bonsplit/Public/BonsplitView.swift | 95 ++ .../Bonsplit/Public/DebugEventLog.swift | 91 ++ .../Sources/Bonsplit/Public/SafeTooltip.swift | 136 ++ .../Public/Types/LayoutSnapshot.swift | 146 ++ .../Public/Types/NavigationDirection.swift | 9 + .../Bonsplit/Public/Types/PaneID.swift | 18 + .../Public/Types/SplitOrientation.swift | 9 + .../Sources/Bonsplit/Public/Types/Tab.swift | 57 + .../Public/Types/TabContextAction.swift | 21 + .../Sources/Bonsplit/Public/Types/TabID.swift | 22 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Tests/BonsplitTests/BonsplitTests.swift | 1053 +++++++++++++ 43 files changed, 8678 insertions(+), 8 deletions(-) delete mode 160000 vendor/bonsplit create mode 100644 vendor/bonsplit/.gitignore create mode 100644 vendor/bonsplit/CHANGELOG.md create mode 100644 vendor/bonsplit/LICENSE create mode 100644 vendor/bonsplit/Package.swift create mode 100644 vendor/bonsplit/README.md create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Models/PaneState.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Models/SplitNode.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Models/SplitState.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Styling/TabBarColors.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Utilities/SplitAnimator.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitContainerView.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitNodeView.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabDragPreview.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabItemView.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/BonsplitConfiguration.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/BonsplitDebugCounters.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/BonsplitDelegate.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/BonsplitView.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/SafeTooltip.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/Types/LayoutSnapshot.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/Types/NavigationDirection.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/Types/PaneID.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/Types/SplitOrientation.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/Types/Tab.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/Types/TabContextAction.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Public/Types/TabID.swift create mode 100644 vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings create mode 100644 vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings create mode 100644 vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift diff --git a/.claude/commands/pull.md b/.claude/commands/pull.md index a31f370527e..d64071ededf 100644 --- a/.claude/commands/pull.md +++ b/.claude/commands/pull.md @@ -5,7 +5,7 @@ Pull latest main and update all submodules to their latest remote main. No commi ## Steps 1. `git pull origin main` -2. For each submodule (ghostty, vendor/bonsplit): +2. For each submodule (ghostty): - `cd ` - `git fetch origin` - Check if behind: `git rev-list HEAD..origin/main --count` diff --git a/.claude/commands/sync-branch.md b/.claude/commands/sync-branch.md index f5b7f7c31b9..12329693d69 100644 --- a/.claude/commands/sync-branch.md +++ b/.claude/commands/sync-branch.md @@ -7,7 +7,7 @@ Get the current branch ready: update all submodules to their latest remote main, ## Steps 1. **Update submodules to latest** - - For each submodule (ghostty, vendor/bonsplit): + - For each submodule (ghostty): - `cd ` - `git fetch origin` - Check if behind: `git rev-list HEAD..origin/main --count` @@ -18,7 +18,7 @@ Get the current branch ready: update all submodules to their latest remote main, 2. **Commit submodule updates on main** - `git checkout main && git pull origin main` - Check if any submodules changed: `git diff --name-only` (look for submodule paths) - - If changed, stage and commit: `git add ghostty vendor/bonsplit && git commit -m "Update submodules: "` + - If changed, stage and commit: `git add ghostty && git commit -m "Update submodules: "` - **Do not push.** Ask the user if they want to push. 3. **Rebase current branch on main** diff --git a/.gitmodules b/.gitmodules index bb703c80c1e..41575475ca0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,6 +2,3 @@ path = ghostty url = https://github.com/darkroomengineering/ghostty.git branch = main -[submodule "vendor/bonsplit"] - path = vendor/bonsplit - url = https://github.com/manaflow-ai/bonsplit.git diff --git a/CLAUDE.md b/CLAUDE.md index a16b192608f..35cc52a4d5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ The app has a **Debug** menu in the macOS menu bar (only in DEBUG builds). Use i - `TabItemView` in `ContentView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add `@EnvironmentObject`, `@ObservedObject` (besides `tab`), or `@Binding` properties without updating the `==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use the precomputed `let` parameters instead. - `TerminalSurface.forceRefresh()` in `GhosttyTerminalView.swift`: called on every keystroke. Do not add allocations, file I/O, or formatting here. - **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn. -- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd && git merge-base --is-ancestor HEAD origin/main`. +- **Submodule safety:** When modifying a submodule (ghostty), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd && git merge-base --is-ancestor HEAD origin/main`. Note: `vendor/bonsplit` is NOT a submodule — it is vendored in-tree (MIT, from the manaflow-ai fork); edit and commit it like any other source directory. - **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc. - **Shortcut policy:** Every new Programa-owned keyboard shortcut must be added to `KeyboardShortcutSettings`, visible/editable in Settings, supported in `~/.config/programa/settings.json`, and documented in the keyboard shortcut and configuration docs. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 893f005272d..63dad578503 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -35,6 +35,7 @@ SOFTWARE. - **License:** MIT License - **Copyright:** Copyright (c) 2026 Alasdair Monk - **Source:** https://github.com/almonk/bonsplit +- **Vendored:** in-tree at `vendor/bonsplit`, from the manaflow-ai/bonsplit fork at commit `b2788b1e77d43f0c114dcf189aa59cae8abb47de` (Example app, www site, and upstream CI omitted) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/vendor/bonsplit b/vendor/bonsplit deleted file mode 160000 index b2788b1e77d..00000000000 --- a/vendor/bonsplit +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b2788b1e77d43f0c114dcf189aa59cae8abb47de diff --git a/vendor/bonsplit/.gitignore b/vendor/bonsplit/.gitignore new file mode 100644 index 00000000000..c5ad9cd2f1c --- /dev/null +++ b/vendor/bonsplit/.gitignore @@ -0,0 +1,59 @@ +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# Xcode +xcuserdata/ +*.xccheckout +*.xcscmblueprint +*.xcodeproj/project.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +DerivedData/ +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# CocoaPods (if used) +Pods/ + +# Carthage (if used) +Carthage/Build/ +Carthage/Checkouts/ + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code coverage +*.gcno +*.gcda +*.gcov + +# Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Archives +*.xcarchive + +# Local build output +build/ diff --git a/vendor/bonsplit/CHANGELOG.md b/vendor/bonsplit/CHANGELOG.md new file mode 100644 index 00000000000..622233b9eed --- /dev/null +++ b/vendor/bonsplit/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to Bonsplit will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.1] - 2025-01-29 + +### Fixed +- Fixed delegate notifications not being sent when closing tabs ([#2](https://github.com/almonk/bonsplit/issues/2)) + - Tabs now correctly communicate through `BonsplitController` for proper delegate callbacks + +### Added +- New public method `closeTab(_ tabId: TabID, inPane paneId: PaneID) -> Bool` for efficient tab closing when pane is known + +## [1.1.0] - 2025-01-26 + +### Added + +#### Two-Way Synchronization API +- **Geometry Query**: Query pane layout with pixel coordinates for integration with external programs + - `layoutSnapshot()` - Get flat list of pane geometries with pixel coordinates + - `treeSnapshot()` - Get full tree structure for external consumption + - `findSplit(_:)` - Check if a split exists by UUID + +- **Programmatic Updates**: Control divider positions from external sources + - `setDividerPosition(_:forSplit:fromExternal:)` - Set divider position with loop prevention + - `setContainerFrame(_:)` - Update container frame when window moves/resizes + +- **Geometry Notifications**: Receive callbacks when geometry changes + - `didChangeGeometry` delegate callback - Notified when any pane geometry changes + - `shouldNotifyDuringDrag` delegate callback - Opt-in to real-time notifications during divider drag + +#### New Types +- `LayoutSnapshot` - Full tree snapshot with pixel coordinates and timestamp +- `PixelRect` - Pixel rectangle for external consumption (Codable, Sendable) +- `PaneGeometry` - Geometry for a single pane including frame and tab info +- `ExternalTreeNode` - Recursive tree representation (enum: pane or split) +- `ExternalPaneNode` - Pane node for external consumption +- `ExternalSplitNode` - Split node with orientation and divider position +- `ExternalTab` - Tab info for external consumption + +#### Debug Tools +- Debug window in Example app for testing synchronization features + +## [1.0.0] - Initial Release + +### Added +- Tab bar with drag-and-drop reordering +- Horizontal and vertical split panes +- 120fps animations +- Configurable appearance and behavior +- Delegate callbacks for all tab and pane events +- Keyboard navigation between panes +- Content view lifecycle options (recreateOnSwitch, keepAllAlive) +- Configuration presets (default, singlePane, readOnly) diff --git a/vendor/bonsplit/LICENSE b/vendor/bonsplit/LICENSE new file mode 100644 index 00000000000..13fc2988c8c --- /dev/null +++ b/vendor/bonsplit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alasdair Monk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/bonsplit/Package.swift b/vendor/bonsplit/Package.swift new file mode 100644 index 00000000000..aa864b7fee5 --- /dev/null +++ b/vendor/bonsplit/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "Bonsplit", + defaultLocalization: "en", + platforms: [ + .macOS(.v14) + ], + products: [ + .library( + name: "Bonsplit", + targets: ["Bonsplit"] + ), + ], + targets: [ + .target( + name: "Bonsplit", + dependencies: [], + path: "Sources/Bonsplit", + resources: [ + .process("Resources") + ] + ), + .testTarget( + name: "BonsplitTests", + dependencies: ["Bonsplit"], + path: "Tests/BonsplitTests" + ), + ] +) diff --git a/vendor/bonsplit/README.md b/vendor/bonsplit/README.md new file mode 100644 index 00000000000..dd78a145c8c --- /dev/null +++ b/vendor/bonsplit/README.md @@ -0,0 +1,623 @@ +# 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`: + +```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 + +```swift +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 + +```swift +// 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 + +```swift +// 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 + +```swift +// 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 + +```swift +// 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: + +```swift +// 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. + +```swift +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. + +```swift +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: + +```swift +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. + +```swift +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: + +```swift +// 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: + +```swift +// 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 + +```swift +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 + +```swift +// 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: + +```swift +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 + +```swift +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 + +```swift +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: + +```swift +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: + +```swift +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 + +```swift +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 diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift new file mode 100644 index 00000000000..9270f80e641 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift @@ -0,0 +1,519 @@ +import Foundation +import SwiftUI + +/// Central controller managing the entire split view state (internal implementation) +@Observable +@MainActor +final class SplitViewController { + /// The root node of the split tree + var rootNode: SplitNode + + /// Currently zoomed pane. When set, rendering should only show this pane. + var zoomedPaneId: PaneID? + + /// Currently focused pane ID + var focusedPaneId: PaneID? + + /// Tab currently being dragged (for visual feedback and hit-testing). + /// This is @Observable so SwiftUI views react (e.g. allowsHitTesting). + var draggingTab: TabItem? + + /// Monotonic counter incremented on each drag start. Used to invalidate stale + /// timeout timers that would otherwise cancel a new drag of the same tab. + var dragGeneration: Int = 0 + + /// Source pane of the dragging tab + var dragSourcePaneId: PaneID? + + /// Non-observable drag session state. Drop delegates read these instead of the + /// @Observable properties above, because SwiftUI batches observable updates and + /// createItemProvider's writes may not be visible to validateDrop/performDrop yet. + @ObservationIgnored var activeDragTab: TabItem? + @ObservationIgnored var activeDragSourcePaneId: PaneID? + + /// When false, drop delegates reject all drags and NSViews are hidden. + /// Mirrors BonsplitController.isInteractive. Must be observable so + /// updateNSView is called to toggle isHidden on the AppKit containers. + var isInteractive: Bool = true + + /// Handler for file/URL drops from external apps (e.g. Finder). + /// Receives the dropped URLs and the pane ID where the drop occurred. + @ObservationIgnored var onFileDrop: ((_ urls: [URL], _ paneId: PaneID) -> Bool)? + + /// During drop, SwiftUI may keep the source tab view alive briefly (default removal animation) + /// even after we've updated the model. Hide it explicitly so it disappears immediately. + var dragHiddenSourceTabId: UUID? + var dragHiddenSourcePaneId: PaneID? + + /// Current frame of the entire split view container + var containerFrame: CGRect = .zero + + /// Flag to prevent notification loops during external updates + var isExternalUpdateInProgress: Bool = false + + /// Timestamp of last geometry notification for debouncing + var lastGeometryNotificationTime: TimeInterval = 0 + + /// Callback for geometry changes + var onGeometryChange: (() -> Void)? + + init(rootNode: SplitNode? = nil) { + if let rootNode { + self.rootNode = rootNode + } else { + // Initialize with a single pane containing a welcome tab + let welcomeTab = TabItem(title: "Welcome", icon: "star") + let initialPane = PaneState(tabs: [welcomeTab]) + self.rootNode = .pane(initialPane) + self.focusedPaneId = initialPane.id + } + } + + // MARK: - Focus Management + + /// Set focus to a specific pane + func focusPane(_ paneId: PaneID) { + guard rootNode.findPane(paneId) != nil else { return } +#if DEBUG + dlog("focus.bonsplit pane=\(paneId.id.uuidString.prefix(5))") +#endif + focusedPaneId = paneId + } + + /// Get the currently focused pane state + var focusedPane: PaneState? { + guard let focusedPaneId else { return nil } + return rootNode.findPane(focusedPaneId) + } + + var zoomedNode: SplitNode? { + guard let zoomedPaneId else { return nil } + return rootNode.findNode(containing: zoomedPaneId) + } + + @discardableResult + func clearPaneZoom() -> Bool { + guard zoomedPaneId != nil else { return false } + zoomedPaneId = nil + return true + } + + @discardableResult + func togglePaneZoom(_ paneId: PaneID) -> Bool { + guard rootNode.findPane(paneId) != nil else { return false } + + if zoomedPaneId == paneId { + zoomedPaneId = nil + return true + } + + // Match Ghostty behavior: a single-pane layout can't be zoomed. + guard rootNode.allPaneIds.count > 1 else { return false } + zoomedPaneId = paneId + focusedPaneId = paneId + return true + } + + // MARK: - Split Operations + + /// Split the specified pane in the given orientation + func splitPane(_ paneId: PaneID, orientation: SplitOrientation, with newTab: TabItem? = nil) { + clearPaneZoom() + rootNode = splitNodeRecursively( + node: rootNode, + targetPaneId: paneId, + orientation: orientation, + newTab: newTab + ) + } + + private func splitNodeRecursively( + node: SplitNode, + targetPaneId: PaneID, + orientation: SplitOrientation, + newTab: TabItem? + ) -> SplitNode { + switch node { + case .pane(let paneState): + if paneState.id == targetPaneId { + // Create new pane - empty if no tab provided (gives developer full control) + let newPane: PaneState + if let tab = newTab { + newPane = PaneState(tabs: [tab]) + } else { + newPane = PaneState(tabs: []) + } + + // Start with divider at the edge so there's no flash before animation + let splitState = SplitState( + orientation: orientation, + first: .pane(paneState), + second: .pane(newPane), + // Keep the model at its steady-state ratio. The view layer can still animate + // from an edge via animationOrigin, but the model should never represent a + // fully-collapsed pane (which can get stuck under view reparenting timing). + dividerPosition: 0.5, + animationOrigin: .fromSecond // New pane slides in from right/bottom + ) + + // Focus the new pane + focusedPaneId = newPane.id + + return .split(splitState) + } + return node + + case .split(let splitState): + splitState.first = splitNodeRecursively( + node: splitState.first, + targetPaneId: targetPaneId, + orientation: orientation, + newTab: newTab + ) + splitState.second = splitNodeRecursively( + node: splitState.second, + targetPaneId: targetPaneId, + orientation: orientation, + newTab: newTab + ) + return .split(splitState) + } + } + + /// Split a pane with a specific tab, optionally inserting the new pane first + func splitPaneWithTab(_ paneId: PaneID, orientation: SplitOrientation, tab: TabItem, insertFirst: Bool) { + clearPaneZoom() + rootNode = splitNodeWithTabRecursively( + node: rootNode, + targetPaneId: paneId, + orientation: orientation, + tab: tab, + insertFirst: insertFirst + ) + } + + private func splitNodeWithTabRecursively( + node: SplitNode, + targetPaneId: PaneID, + orientation: SplitOrientation, + tab: TabItem, + insertFirst: Bool + ) -> SplitNode { + switch node { + case .pane(let paneState): + if paneState.id == targetPaneId { + // Create new pane with the tab + let newPane = PaneState(tabs: [tab]) + + // Start with divider at the edge so there's no flash before animation + let splitState: SplitState + if insertFirst { + // New pane goes first (left or top). + splitState = SplitState( + orientation: orientation, + first: .pane(newPane), + second: .pane(paneState), + dividerPosition: 0.5, + animationOrigin: .fromFirst + ) + } else { + // New pane goes second (right or bottom). + splitState = SplitState( + orientation: orientation, + first: .pane(paneState), + second: .pane(newPane), + dividerPosition: 0.5, + animationOrigin: .fromSecond + ) + } + + // Focus the new pane + focusedPaneId = newPane.id + + return .split(splitState) + } + return node + + case .split(let splitState): + splitState.first = splitNodeWithTabRecursively( + node: splitState.first, + targetPaneId: targetPaneId, + orientation: orientation, + tab: tab, + insertFirst: insertFirst + ) + splitState.second = splitNodeWithTabRecursively( + node: splitState.second, + targetPaneId: targetPaneId, + orientation: orientation, + tab: tab, + insertFirst: insertFirst + ) + return .split(splitState) + } + } + + /// Close a pane and collapse the split + func closePane(_ paneId: PaneID) { + // Don't close the last pane + guard rootNode.allPaneIds.count > 1 else { return } + + let (newRoot, siblingPaneId) = closePaneRecursively(node: rootNode, targetPaneId: paneId) + + if let newRoot { + rootNode = newRoot + } + + // Focus the sibling or first available pane + if let siblingPaneId { + focusedPaneId = siblingPaneId + } else if let firstPane = rootNode.allPaneIds.first { + focusedPaneId = firstPane + } + + if let zoomedPaneId, rootNode.findPane(zoomedPaneId) == nil { + self.zoomedPaneId = nil + } + } + + private func closePaneRecursively( + node: SplitNode, + targetPaneId: PaneID + ) -> (SplitNode?, PaneID?) { + switch node { + case .pane(let paneState): + if paneState.id == targetPaneId { + return (nil, nil) + } + return (node, nil) + + case .split(let splitState): + // Check if either direct child is the target + if case .pane(let firstPane) = splitState.first, firstPane.id == targetPaneId { + let focusTarget = splitState.second.allPaneIds.first + return (splitState.second, focusTarget) + } + + if case .pane(let secondPane) = splitState.second, secondPane.id == targetPaneId { + let focusTarget = splitState.first.allPaneIds.first + return (splitState.first, focusTarget) + } + + // Recursively check children + let (newFirst, focusFromFirst) = closePaneRecursively(node: splitState.first, targetPaneId: targetPaneId) + if newFirst == nil { + return (splitState.second, splitState.second.allPaneIds.first) + } + + let (newSecond, focusFromSecond) = closePaneRecursively(node: splitState.second, targetPaneId: targetPaneId) + if newSecond == nil { + return (splitState.first, splitState.first.allPaneIds.first) + } + + if let newFirst { splitState.first = newFirst } + if let newSecond { splitState.second = newSecond } + + return (.split(splitState), focusFromFirst ?? focusFromSecond) + } + } + + // MARK: - Tab Operations + + /// Add a tab to the focused pane (or specified pane) + func addTab(_ tab: TabItem, toPane paneId: PaneID? = nil, atIndex index: Int? = nil) { + let targetPaneId = paneId ?? focusedPaneId + guard let targetPaneId, + let pane = rootNode.findPane(targetPaneId) else { return } + + if let index { + pane.insertTab(tab, at: index) + } else { + pane.addTab(tab) + } + } + + /// Move a tab from one pane to another + func moveTab(_ tab: TabItem, from sourcePaneId: PaneID, to targetPaneId: PaneID, atIndex index: Int? = nil) { + guard let sourcePane = rootNode.findPane(sourcePaneId), + let targetPane = rootNode.findPane(targetPaneId) else { return } + + // Remove from source + sourcePane.removeTab(tab.id) + + // Add to target + if let index { + targetPane.insertTab(tab, at: index) + } else { + targetPane.addTab(tab) + } + + // Focus target pane + focusPane(targetPaneId) + + // If source pane is now empty and not the only pane, close it + if sourcePane.tabs.isEmpty && rootNode.allPaneIds.count > 1 { + closePane(sourcePaneId) + } + } + + /// Close a tab in a specific pane + func closeTab(_ tabId: UUID, inPane paneId: PaneID) { + guard let pane = rootNode.findPane(paneId) else { return } + + pane.removeTab(tabId) + + // If pane is now empty and not the only pane, close it + if pane.tabs.isEmpty && rootNode.allPaneIds.count > 1 { + closePane(paneId) + } + } + + // MARK: - Keyboard Navigation + + /// Navigate focus to an adjacent pane based on spatial position + func navigateFocus(direction: NavigationDirection) { + guard let currentPaneId = focusedPaneId else { return } + + let allPaneBounds = rootNode.computePaneBounds() + guard let currentBounds = allPaneBounds.first(where: { $0.paneId == currentPaneId })?.bounds else { return } + + if let targetPaneId = findBestNeighbor(from: currentBounds, currentPaneId: currentPaneId, + direction: direction, allPaneBounds: allPaneBounds) { + focusPane(targetPaneId) + } + // No neighbor found = at edge, do nothing + } + + /// Find the closest pane in the requested direction from the given pane. + func adjacentPane(to paneId: PaneID, direction: NavigationDirection) -> PaneID? { + let allPaneBounds = rootNode.computePaneBounds() + guard let currentBounds = allPaneBounds.first(where: { $0.paneId == paneId })?.bounds else { + return nil + } + return findBestNeighbor( + from: currentBounds, + currentPaneId: paneId, + direction: direction, + allPaneBounds: allPaneBounds + ) + } + + private func findBestNeighbor(from currentBounds: CGRect, currentPaneId: PaneID, + direction: NavigationDirection, allPaneBounds: [PaneBounds]) -> PaneID? { + let epsilon: CGFloat = 0.001 + + // Filter to panes in the target direction + let candidates = allPaneBounds.filter { paneBounds in + guard paneBounds.paneId != currentPaneId else { return false } + let b = paneBounds.bounds + switch direction { + case .left: return b.maxX <= currentBounds.minX + epsilon + case .right: return b.minX >= currentBounds.maxX - epsilon + case .up: return b.maxY <= currentBounds.minY + epsilon + case .down: return b.minY >= currentBounds.maxY - epsilon + } + } + + guard !candidates.isEmpty else { return nil } + + // Score by overlap (perpendicular axis) and distance + let scored: [(PaneID, CGFloat, CGFloat)] = candidates.map { c in + let overlap: CGFloat + let distance: CGFloat + + switch direction { + case .left, .right: + // Vertical overlap for horizontal movement + overlap = max(0, min(currentBounds.maxY, c.bounds.maxY) - max(currentBounds.minY, c.bounds.minY)) + distance = direction == .left ? (currentBounds.minX - c.bounds.maxX) : (c.bounds.minX - currentBounds.maxX) + case .up, .down: + // Horizontal overlap for vertical movement + overlap = max(0, min(currentBounds.maxX, c.bounds.maxX) - max(currentBounds.minX, c.bounds.minX)) + distance = direction == .up ? (currentBounds.minY - c.bounds.maxY) : (c.bounds.minY - currentBounds.maxY) + } + + return (c.paneId, overlap, distance) + } + + // Sort: prefer more overlap, then closer distance + let sorted = scored.sorted { a, b in + if abs(a.1 - b.1) > epsilon { return a.1 > b.1 } + return a.2 < b.2 + } + + return sorted.first?.0 + } + + /// Create a new tab in the focused pane + func createNewTab() { + guard let pane = focusedPane else { return } + let count = pane.tabs.count + 1 + let newTab = TabItem(title: "Untitled \(count)", icon: "doc") + pane.addTab(newTab) + } + + /// Close the currently selected tab in the focused pane + func closeSelectedTab() { + guard let pane = focusedPane, + let selectedTabId = pane.selectedTabId else { return } + closeTab(selectedTabId, inPane: pane.id) + } + + /// Select the previous tab in the focused pane + func selectPreviousTab() { + guard let pane = focusedPane, + let selectedTabId = pane.selectedTabId, + let currentIndex = pane.tabs.firstIndex(where: { $0.id == selectedTabId }), + !pane.tabs.isEmpty else { return } + + let newIndex = currentIndex > 0 ? currentIndex - 1 : pane.tabs.count - 1 + pane.selectTab(pane.tabs[newIndex].id) + } + + /// Select the next tab in the focused pane + func selectNextTab() { + guard let pane = focusedPane, + let selectedTabId = pane.selectedTabId, + let currentIndex = pane.tabs.firstIndex(where: { $0.id == selectedTabId }), + !pane.tabs.isEmpty else { return } + + let newIndex = currentIndex < pane.tabs.count - 1 ? currentIndex + 1 : 0 + pane.selectTab(pane.tabs[newIndex].id) + } + + // MARK: - Split State Access + + /// Find a split state by its UUID + func findSplit(_ splitId: UUID) -> SplitState? { + return findSplitRecursively(in: rootNode, id: splitId) + } + + private func findSplitRecursively(in node: SplitNode, id: UUID) -> SplitState? { + switch node { + case .pane: + return nil + case .split(let splitState): + if splitState.id == id { + return splitState + } + if let found = findSplitRecursively(in: splitState.first, id: id) { + return found + } + return findSplitRecursively(in: splitState.second, id: id) + } + } + + /// Get all split states in the tree + var allSplits: [SplitState] { + return collectSplits(from: rootNode) + } + + private func collectSplits(from node: SplitNode) -> [SplitState] { + switch node { + case .pane: + return [] + case .split(let splitState): + return [splitState] + collectSplits(from: splitState.first) + collectSplits(from: splitState.second) + } + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Models/PaneState.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/PaneState.swift new file mode 100644 index 00000000000..d08df2fbbeb --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/PaneState.swift @@ -0,0 +1,108 @@ +import Foundation +import SwiftUI + +/// State for a single pane (leaf node in the split tree) +@Observable +final class PaneState: Identifiable { + let id: PaneID + var tabs: [TabItem] + var selectedTabId: UUID? + + init( + id: PaneID = PaneID(), + tabs: [TabItem] = [], + selectedTabId: UUID? = nil + ) { + self.id = id + self.tabs = tabs + self.selectedTabId = selectedTabId ?? tabs.first?.id + } + + /// Currently selected tab + var selectedTab: TabItem? { + tabs.first { $0.id == selectedTabId } + } + + /// Select a tab by ID + func selectTab(_ tabId: UUID) { + guard tabs.contains(where: { $0.id == tabId }) else { return } + selectedTabId = tabId + } + + /// Add a new tab + func addTab(_ tab: TabItem, select: Bool = true) { + let pinnedCount = tabs.filter { $0.isPinned }.count + let insertIndex = tab.isPinned ? pinnedCount : tabs.count + tabs.insert(tab, at: insertIndex) + if select { + selectedTabId = tab.id + } + } + + /// Insert a tab at a specific index + func insertTab(_ tab: TabItem, at index: Int, select: Bool = true) { + let pinnedCount = tabs.filter { $0.isPinned }.count + let requested = min(max(0, index), tabs.count) + let safeIndex: Int + if tab.isPinned { + safeIndex = min(requested, pinnedCount) + } else { + safeIndex = max(requested, pinnedCount) + } + tabs.insert(tab, at: safeIndex) + if select { + selectedTabId = tab.id + } + } + + /// Remove a tab and return it + @discardableResult + func removeTab(_ tabId: UUID) -> TabItem? { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } + let tab = tabs.remove(at: index) + + // If we removed the selected tab, keep the index stable when possible: + // prefer selecting the tab that moved into the removed tab's slot (the "next" tab), + // and only fall back to selecting the previous tab when we removed the last tab. + if selectedTabId == tabId { + if !tabs.isEmpty { + let newIndex = min(index, max(0, tabs.count - 1)) + selectedTabId = tabs[newIndex].id + } else { + selectedTabId = nil + } + } + + return tab + } + + /// Move a tab within this pane + func moveTab(from sourceIndex: Int, to destinationIndex: Int) { + guard tabs.indices.contains(sourceIndex), + destinationIndex >= 0, destinationIndex <= tabs.count else { return } + + // Treat dropping "on itself" or "after itself" as a no-op. + // This avoids remove/insert churn that can cause brief visual artifacts during drag/drop. + if destinationIndex == sourceIndex || destinationIndex == sourceIndex + 1 { + return + } + + let tab = tabs.remove(at: sourceIndex) + let requestedIndex = destinationIndex > sourceIndex ? destinationIndex - 1 : destinationIndex + let pinnedCount = tabs.filter { $0.isPinned }.count + let adjustedIndex: Int + if tab.isPinned { + adjustedIndex = min(requestedIndex, pinnedCount) + } else { + adjustedIndex = max(requestedIndex, pinnedCount) + } + let safeIndex = min(max(0, adjustedIndex), tabs.count) + tabs.insert(tab, at: safeIndex) + } +} + +extension PaneState: Equatable { + static func == (lhs: PaneState, rhs: PaneState) -> Bool { + lhs.id == rhs.id + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Models/SplitNode.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/SplitNode.swift new file mode 100644 index 00000000000..24b208fa07a --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/SplitNode.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Represents a pane with its computed bounds in normalized coordinates (0-1) +struct PaneBounds { + let paneId: PaneID + let bounds: CGRect +} + +/// Recursive structure representing the split tree +/// - pane: A leaf node containing a single pane with tabs +/// - split: A branch node containing two children with a divider +indirect enum SplitNode: Identifiable, Equatable { + case pane(PaneState) + case split(SplitState) + + var id: UUID { + switch self { + case .pane(let state): + return state.id.id + case .split(let state): + return state.id + } + } + + /// Find a pane by its ID + func findPane(_ paneId: PaneID) -> PaneState? { + switch self { + case .pane(let state): + return state.id == paneId ? state : nil + case .split(let state): + return state.first.findPane(paneId) ?? state.second.findPane(paneId) + } + } + + /// Find the leaf node for a pane by ID. + func findNode(containing paneId: PaneID) -> SplitNode? { + switch self { + case .pane(let state): + return state.id == paneId ? self : nil + case .split(let state): + return state.first.findNode(containing: paneId) ?? state.second.findNode(containing: paneId) + } + } + + /// Get all pane IDs in the tree + var allPaneIds: [PaneID] { + switch self { + case .pane(let state): + return [state.id] + case .split(let state): + return state.first.allPaneIds + state.second.allPaneIds + } + } + + /// Get all panes in the tree + var allPanes: [PaneState] { + switch self { + case .pane(let state): + return [state] + case .split(let state): + return state.first.allPanes + state.second.allPanes + } + } + + /// Discriminator for detecting structural changes in the tree + enum NodeType: Equatable { + case pane + case split + } + + var nodeType: NodeType { + switch self { + case .pane: return .pane + case .split: return .split + } + } + + static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { + lhs.id == rhs.id + } + + /// Compute normalized bounds (0-1) for all panes in the tree + /// - Parameter availableRect: The rect available for this subtree (starts as unit rect) + /// - Returns: Array of pane IDs with their computed bounds + func computePaneBounds(in availableRect: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1)) -> [PaneBounds] { + switch self { + case .pane(let paneState): + return [PaneBounds(paneId: paneState.id, bounds: availableRect)] + + case .split(let splitState): + let dividerPos = splitState.dividerPosition + let firstRect: CGRect + let secondRect: CGRect + + switch splitState.orientation { + case .horizontal: // Side-by-side: first=LEFT, second=RIGHT + firstRect = CGRect(x: availableRect.minX, y: availableRect.minY, + width: availableRect.width * dividerPos, height: availableRect.height) + secondRect = CGRect(x: availableRect.minX + availableRect.width * dividerPos, y: availableRect.minY, + width: availableRect.width * (1 - dividerPos), height: availableRect.height) + case .vertical: // Stacked: first=TOP, second=BOTTOM + firstRect = CGRect(x: availableRect.minX, y: availableRect.minY, + width: availableRect.width, height: availableRect.height * dividerPos) + secondRect = CGRect(x: availableRect.minX, y: availableRect.minY + availableRect.height * dividerPos, + width: availableRect.width, height: availableRect.height * (1 - dividerPos)) + } + + return splitState.first.computePaneBounds(in: firstRect) + + splitState.second.computePaneBounds(in: secondRect) + } + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Models/SplitState.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/SplitState.swift new file mode 100644 index 00000000000..1f1a7b744ab --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/SplitState.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftUI + +/// Direction from which a new split animates in +enum SplitAnimationOrigin { + case fromFirst // New pane slides in from start (left/top) + case fromSecond // New pane slides in from end (right/bottom) +} + +/// State for a split node (branch in the split tree) +@Observable +final class SplitState: Identifiable { + let id: UUID + var orientation: SplitOrientation + var first: SplitNode + var second: SplitNode + var dividerPosition: CGFloat // 0.0 to 1.0 + + /// Animation origin for entry animation (nil = no animation needed) + var animationOrigin: SplitAnimationOrigin? + + init( + id: UUID = UUID(), + orientation: SplitOrientation, + first: SplitNode, + second: SplitNode, + dividerPosition: CGFloat = 0.5, + animationOrigin: SplitAnimationOrigin? = nil + ) { + self.id = id + self.orientation = orientation + self.first = first + self.second = second + self.dividerPosition = dividerPosition + self.animationOrigin = animationOrigin + } +} + +extension SplitState: Equatable { + static func == (lhs: SplitState, rhs: SplitState) -> Bool { + lhs.id == rhs.id + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift new file mode 100644 index 00000000000..4436760f06f --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift @@ -0,0 +1,151 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +/// Custom UTTypes for tab drag and drop +extension UTType { + static var tabItem: UTType { + UTType(exportedAs: "com.splittabbar.tabitem") + } + + static var tabTransfer: UTType { + UTType(exportedAs: "com.splittabbar.tabtransfer", conformingTo: .data) + } +} + +/// Represents a single tab in a pane's tab bar (internal representation) +struct TabItem: Identifiable, Hashable, Codable { + let id: UUID + var title: String + var hasCustomTitle: Bool + var icon: String? + var iconImageData: Data? + var kind: String? + var isDirty: Bool + var showsNotificationBadge: Bool + var isLoading: Bool + var isPinned: Bool + + init( + id: UUID = UUID(), + title: String, + hasCustomTitle: Bool = false, + icon: String? = "doc.text", + iconImageData: Data? = nil, + kind: String? = nil, + isDirty: Bool = false, + showsNotificationBadge: Bool = false, + isLoading: Bool = false, + isPinned: Bool = false + ) { + self.id = id + self.title = title + self.hasCustomTitle = hasCustomTitle + self.icon = icon + self.iconImageData = iconImageData + self.kind = kind + self.isDirty = isDirty + self.showsNotificationBadge = showsNotificationBadge + self.isLoading = isLoading + self.isPinned = isPinned + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: TabItem, rhs: TabItem) -> Bool { + lhs.id == rhs.id + } + + private enum CodingKeys: String, CodingKey { + case id + case title + case hasCustomTitle + case icon + case iconImageData + case kind + case isDirty + case showsNotificationBadge + case isLoading + case isPinned + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.id = try c.decode(UUID.self, forKey: .id) + self.title = try c.decode(String.self, forKey: .title) + self.hasCustomTitle = try c.decodeIfPresent(Bool.self, forKey: .hasCustomTitle) ?? false + self.icon = try c.decodeIfPresent(String.self, forKey: .icon) + self.iconImageData = try c.decodeIfPresent(Data.self, forKey: .iconImageData) + self.kind = try c.decodeIfPresent(String.self, forKey: .kind) + self.isDirty = try c.decodeIfPresent(Bool.self, forKey: .isDirty) ?? false + self.showsNotificationBadge = try c.decodeIfPresent(Bool.self, forKey: .showsNotificationBadge) ?? false + self.isLoading = try c.decodeIfPresent(Bool.self, forKey: .isLoading) ?? false + self.isPinned = try c.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(title, forKey: .title) + try c.encode(hasCustomTitle, forKey: .hasCustomTitle) + try c.encodeIfPresent(icon, forKey: .icon) + try c.encodeIfPresent(iconImageData, forKey: .iconImageData) + try c.encodeIfPresent(kind, forKey: .kind) + try c.encode(isDirty, forKey: .isDirty) + try c.encode(showsNotificationBadge, forKey: .showsNotificationBadge) + try c.encode(isLoading, forKey: .isLoading) + try c.encode(isPinned, forKey: .isPinned) + } +} + +// MARK: - Transferable for Drag & Drop + +extension TabItem: Transferable { + static var transferRepresentation: some TransferRepresentation { + CodableRepresentation(contentType: .tabItem) + } +} + +/// Transfer data that includes source pane information for cross-pane moves +struct TabTransferData: Codable, Transferable { + let tab: TabItem + let sourcePaneId: UUID + let sourceProcessId: Int32 + + init(tab: TabItem, sourcePaneId: UUID, sourceProcessId: Int32 = Int32(ProcessInfo.processInfo.processIdentifier)) { + self.tab = tab + self.sourcePaneId = sourcePaneId + self.sourceProcessId = sourceProcessId + } + + var isFromCurrentProcess: Bool { + sourceProcessId == Int32(ProcessInfo.processInfo.processIdentifier) + } + + private enum CodingKeys: String, CodingKey { + case tab + case sourcePaneId + case sourceProcessId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.tab = try container.decode(TabItem.self, forKey: .tab) + self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) + // Legacy payloads won't include this field. Treat as foreign process to reject cross-instance drops. + self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(tab, forKey: .tab) + try container.encode(sourcePaneId, forKey: .sourcePaneId) + try container.encode(sourceProcessId, forKey: .sourceProcessId) + } + + static var transferRepresentation: some TransferRepresentation { + CodableRepresentation(contentType: .tabTransfer) + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Styling/TabBarColors.swift new file mode 100644 index 00000000000..f86a5fe93db --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -0,0 +1,276 @@ +import SwiftUI +import AppKit + +/// Native macOS colors for the tab bar +enum TabBarColors { + private enum Constants { + static let darkTextAlpha: CGFloat = 0.82 + static let darkSecondaryTextAlpha: CGFloat = 0.62 + static let lightTextAlpha: CGFloat = 0.82 + static let lightSecondaryTextAlpha: CGFloat = 0.68 + } + + private static func chromeBackgroundColor( + for appearance: BonsplitConfiguration.Appearance + ) -> NSColor? { + guard let value = appearance.chromeColors.backgroundHex else { return nil } + return NSColor(bonsplitHex: value) + } + + private static func chromeBorderColor( + for appearance: BonsplitConfiguration.Appearance + ) -> NSColor? { + guard let value = appearance.chromeColors.borderHex else { return nil } + return NSColor(bonsplitHex: value) + } + + private static func effectiveBackgroundColor( + for appearance: BonsplitConfiguration.Appearance, + fallback fallbackColor: NSColor + ) -> NSColor { + chromeBackgroundColor(for: appearance) ?? fallbackColor + } + + private static func effectiveTextColor( + for appearance: BonsplitConfiguration.Appearance, + secondary: Bool + ) -> NSColor { + guard let custom = chromeBackgroundColor(for: appearance) else { + return secondary ? .secondaryLabelColor : .labelColor + } + + if custom.isBonsplitLightColor { + let alpha = secondary ? Constants.darkSecondaryTextAlpha : Constants.darkTextAlpha + return NSColor.black.withAlphaComponent(alpha) + } + + let alpha = secondary ? Constants.lightSecondaryTextAlpha : Constants.lightTextAlpha + return NSColor.white.withAlphaComponent(alpha) + } + + static func paneBackground(for appearance: BonsplitConfiguration.Appearance) -> Color { + Color(nsColor: effectiveBackgroundColor(for: appearance, fallback: .textBackgroundColor)) + } + + static func nsColorPaneBackground(for appearance: BonsplitConfiguration.Appearance) -> NSColor { + effectiveBackgroundColor(for: appearance, fallback: .textBackgroundColor) + } + + // MARK: - Tab Bar Background + + static var barBackground: Color { + Color(nsColor: .windowBackgroundColor) + } + + static func barBackground(for appearance: BonsplitConfiguration.Appearance) -> Color { + Color(nsColor: effectiveBackgroundColor(for: appearance, fallback: .windowBackgroundColor)) + } + + static var barMaterial: Material { + .bar + } + + // MARK: - Tab States + + static var activeTabBackground: Color { + Color(nsColor: .controlBackgroundColor) + } + + static func activeTabBackground(for appearance: BonsplitConfiguration.Appearance) -> Color { + guard let custom = chromeBackgroundColor(for: appearance) else { + return activeTabBackground + } + let adjusted = custom.isBonsplitLightColor + ? custom.bonsplitDarken(by: 0.065) + : custom.bonsplitLighten(by: 0.12) + return Color(nsColor: adjusted) + } + + static var hoveredTabBackground: Color { + Color(nsColor: .controlBackgroundColor).opacity(0.5) + } + + static func hoveredTabBackground(for appearance: BonsplitConfiguration.Appearance) -> Color { + guard let custom = chromeBackgroundColor(for: appearance) else { + return hoveredTabBackground + } + let adjusted = custom.isBonsplitLightColor + ? custom.bonsplitDarken(by: 0.03) + : custom.bonsplitLighten(by: 0.07) + return Color(nsColor: adjusted.withAlphaComponent(0.78)) + } + + static var inactiveTabBackground: Color { + .clear + } + + // MARK: - Text Colors + + static var activeText: Color { + Color(nsColor: .labelColor) + } + + static func activeText(for appearance: BonsplitConfiguration.Appearance) -> Color { + Color(nsColor: effectiveTextColor(for: appearance, secondary: false)) + } + + static func nsColorActiveText(for appearance: BonsplitConfiguration.Appearance) -> NSColor { + effectiveTextColor(for: appearance, secondary: false) + } + + static var inactiveText: Color { + Color(nsColor: .secondaryLabelColor) + } + + static func inactiveText(for appearance: BonsplitConfiguration.Appearance) -> Color { + Color(nsColor: effectiveTextColor(for: appearance, secondary: true)) + } + + static func nsColorInactiveText(for appearance: BonsplitConfiguration.Appearance) -> NSColor { + effectiveTextColor(for: appearance, secondary: true) + } + + static func splitActionIcon(for appearance: BonsplitConfiguration.Appearance, isPressed: Bool) -> Color { + Color(nsColor: nsColorSplitActionIcon(for: appearance, isPressed: isPressed)) + } + + static func nsColorSplitActionIcon( + for appearance: BonsplitConfiguration.Appearance, + isPressed: Bool + ) -> NSColor { + isPressed ? nsColorActiveText(for: appearance) : nsColorInactiveText(for: appearance) + } + + // MARK: - Borders & Indicators + + static var separator: Color { + Color(nsColor: .separatorColor) + } + + static func separator(for appearance: BonsplitConfiguration.Appearance) -> Color { + Color(nsColor: nsColorSeparator(for: appearance)) + } + + static func nsColorSeparator(for appearance: BonsplitConfiguration.Appearance) -> NSColor { + if let explicit = chromeBorderColor(for: appearance) { + return explicit + } + + guard let custom = chromeBackgroundColor(for: appearance) else { + return .separatorColor + } + let alpha: CGFloat = custom.isBonsplitLightColor ? 0.26 : 0.36 + let tone = custom.isBonsplitLightColor + ? custom.bonsplitDarken(by: 0.12) + : custom.bonsplitLighten(by: 0.16) + return tone.withAlphaComponent(alpha) + } + + static var dropIndicator: Color { + Color.accentColor + } + + static func dropIndicator(for appearance: BonsplitConfiguration.Appearance) -> Color { + _ = appearance + return dropIndicator + } + + static var focusRing: Color { + Color.accentColor.opacity(0.5) + } + + static var dirtyIndicator: Color { + Color(nsColor: .labelColor).opacity(0.6) + } + + static func dirtyIndicator(for appearance: BonsplitConfiguration.Appearance) -> Color { + guard chromeBackgroundColor(for: appearance) != nil else { return dirtyIndicator } + return activeText(for: appearance).opacity(0.72) + } + + static var notificationBadge: Color { + Color(nsColor: .systemBlue) + } + + static func notificationBadge(for appearance: BonsplitConfiguration.Appearance) -> Color { + _ = appearance + return notificationBadge + } + + // MARK: - Shadows + + static var tabShadow: Color { + Color.black.opacity(0.08) + } +} + +private extension NSColor { + private static let bonsplitHexDigits = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + + convenience init?(bonsplitHex value: String) { + var hex = value.trimmingCharacters(in: .whitespacesAndNewlines) + if hex.hasPrefix("#") { + hex.removeFirst() + } + guard hex.count == 6 || hex.count == 8 else { return nil } + guard hex.unicodeScalars.allSatisfy({ Self.bonsplitHexDigits.contains($0) }) else { return nil } + guard let rgba = UInt64(hex, radix: 16) else { return nil } + let red: CGFloat + let green: CGFloat + let blue: CGFloat + let alpha: CGFloat + if hex.count == 8 { + red = CGFloat((rgba & 0xFF000000) >> 24) / 255.0 + green = CGFloat((rgba & 0x00FF0000) >> 16) / 255.0 + blue = CGFloat((rgba & 0x0000FF00) >> 8) / 255.0 + alpha = CGFloat(rgba & 0x000000FF) / 255.0 + } else { + red = CGFloat((rgba & 0xFF0000) >> 16) / 255.0 + green = CGFloat((rgba & 0x00FF00) >> 8) / 255.0 + blue = CGFloat(rgba & 0x0000FF) / 255.0 + alpha = 1.0 + } + self.init(red: red, green: green, blue: blue, alpha: alpha) + } + + var isBonsplitLightColor: Bool { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + let color = usingColorSpace(.sRGB) ?? self + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue) + return luminance > 0.5 + } + + func bonsplitLighten(by amount: CGFloat) -> NSColor { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + let color = usingColorSpace(.sRGB) ?? self + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return NSColor( + red: min(1.0, red + amount), + green: min(1.0, green + amount), + blue: min(1.0, blue + amount), + alpha: alpha + ) + } + + func bonsplitDarken(by amount: CGFloat) -> NSColor { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + let color = usingColorSpace(.sRGB) ?? self + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return NSColor( + red: max(0.0, red - amount), + green: max(0.0, green - amount), + blue: max(0.0, blue - amount), + alpha: alpha + ) + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift new file mode 100644 index 00000000000..5e7d657304b --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Sizing and spacing constants for the tab bar (following macOS HIG) +enum TabBarMetrics { + // MARK: - Tab Bar + + static let barHeight: CGFloat = 30 + static let barPadding: CGFloat = 0 + + // MARK: - Individual Tabs + + static let tabHeight: CGFloat = 30 + static let tabMinWidth: CGFloat = 48 + static let tabMaxWidth: CGFloat = 220 + static let tabCornerRadius: CGFloat = 0 + static let tabHorizontalPadding: CGFloat = 6 + static let tabSpacing: CGFloat = 0 + static let activeIndicatorHeight: CGFloat = 2 + + // MARK: - Tab Content + + static let iconSize: CGFloat = 14 + static let titleFontSize: CGFloat = 11 + static let closeButtonSize: CGFloat = 16 + static let closeIconSize: CGFloat = 9 + static let dirtyIndicatorSize: CGFloat = 8 + static let notificationBadgeSize: CGFloat = 6 + static let contentSpacing: CGFloat = 6 + + // MARK: - Drop Indicator + + static let dropIndicatorWidth: CGFloat = 2 + static let dropIndicatorHeight: CGFloat = 20 + + // MARK: - Split View + + static let minimumPaneWidth: CGFloat = 100 + static let minimumPaneHeight: CGFloat = 100 + static let dividerThickness: CGFloat = 1 + + // MARK: - Animations + + static let selectionDuration: Double = 0.15 + static let closeDuration: Double = 0.2 + static let reorderDuration: Double = 0.3 + static let reorderBounce: Double = 0.15 + static let hoverDuration: Double = 0.1 + + // MARK: - Split Animations (120fps via CADisplayLink) + + /// Duration for split entry animation (fast and snappy like Hyprland) + static let splitAnimationDuration: Double = 0.15 +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Utilities/SplitAnimator.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Utilities/SplitAnimator.swift new file mode 100644 index 00000000000..0d2dbcbc588 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Utilities/SplitAnimator.swift @@ -0,0 +1,140 @@ +import Foundation +import AppKit +import QuartzCore +import CoreVideo + +/// Animates split view divider positions with display-synced updates and pixel-perfect positioning +@MainActor +final class SplitAnimator { + + // MARK: - Types + + private struct Animation { + weak var splitView: NSSplitView? + let startPosition: CGFloat + let endPosition: CGFloat + let startTime: CFTimeInterval + let duration: CFTimeInterval + var onComplete: (() -> Void)? + } + + // MARK: - Properties + + private var displayLink: CVDisplayLink? + private var animations: [UUID: Animation] = [:] + + /// Shared animator instance + static let shared = SplitAnimator() + + /// Default animation duration in seconds + nonisolated static let defaultAnimationDuration: CFTimeInterval = 0.16 + // MARK: - Initialization + + private init() { + setupDisplayLink() + } + + deinit { + if let displayLink, CVDisplayLinkIsRunning(displayLink) { + CVDisplayLinkStop(displayLink) + } + } + + // MARK: - Display Link + + private func setupDisplayLink() { + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let link else { return } + + let callback: CVDisplayLinkOutputCallback = { _, _, _, _, _, context in + let animator = Unmanaged.fromOpaque(context!).takeUnretainedValue() + DispatchQueue.main.async { + Task { @MainActor in + animator.tick() + } + } + return kCVReturnSuccess + } + + CVDisplayLinkSetOutputCallback(link, callback, Unmanaged.passUnretained(self).toOpaque()) + displayLink = link + } + + // MARK: - Animation Control + + @discardableResult + func animate( + splitView: NSSplitView, + from startPosition: CGFloat, + to endPosition: CGFloat, + duration: CFTimeInterval = SplitAnimator.defaultAnimationDuration, + onComplete: (() -> Void)? = nil + ) -> UUID { + let id = UUID() + + splitView.layoutSubtreeIfNeeded() + splitView.setPosition(round(startPosition), ofDividerAt: 0) + splitView.layoutSubtreeIfNeeded() + + animations[id] = Animation( + splitView: splitView, + startPosition: startPosition, + endPosition: endPosition, + startTime: CACurrentMediaTime(), + duration: duration, + onComplete: onComplete + ) + + if let displayLink, !CVDisplayLinkIsRunning(displayLink) { + CVDisplayLinkStart(displayLink) + } + + return id + } + + func cancel(_ id: UUID) { + animations.removeValue(forKey: id) + stopIfNeeded() + } + + // MARK: - Frame Update + + private func tick() { + let currentTime = CACurrentMediaTime() + var completedIds: [UUID] = [] + + for (id, animation) in animations { + guard let splitView = animation.splitView else { + completedIds.append(id) + continue + } + + let elapsed = currentTime - animation.startTime + let progress = min(elapsed / animation.duration, 1.0) + let eased = progress == 1.0 ? 1.0 : 1.0 - pow(2.0, -10.0 * progress) + + let position = animation.startPosition + (animation.endPosition - animation.startPosition) * eased + + // Round to whole pixels to prevent artifacts + splitView.setPosition(round(position), ofDividerAt: 0) + + if progress >= 1.0 { + completedIds.append(id) + animation.onComplete?() + } + } + + for id in completedIds { + animations.removeValue(forKey: id) + } + + stopIfNeeded() + } + + private func stopIfNeeded() { + if animations.isEmpty, let displayLink, CVDisplayLinkIsRunning(displayLink) { + CVDisplayLinkStop(displayLink) + } + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift new file mode 100644 index 00000000000..5e0a2577465 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -0,0 +1,577 @@ +import SwiftUI +import UniformTypeIdentifiers +import AppKit + +/// Drop zone positions for creating splits +public enum DropZone: Equatable { + case center + case left + case right + case top + case bottom + + var orientation: SplitOrientation? { + switch self { + case .left, .right: return .horizontal + case .top, .bottom: return .vertical + case .center: return nil + } + } + + var insertsFirst: Bool { + switch self { + case .left, .top: return true + default: return false + } + } +} + +// MARK: - Environment key for portal-hosted views + +/// Environment key so portal-hosted content (e.g. terminal surfaces rendered +/// above SwiftUI via an AppKit portal) can read the active drop zone and show +/// their own overlay, since the SwiftUI placeholder is hidden behind the portal. +private struct ActiveDropZoneKey: EnvironmentKey { + static let defaultValue: DropZone? = nil +} + +public extension EnvironmentValues { + var paneDropZone: DropZone? { + get { self[ActiveDropZoneKey.self] } + set { self[ActiveDropZoneKey.self] = newValue } + } +} + +/// Drop lifecycle state to prevent dropUpdated from re-setting state after performDrop +enum PaneDropLifecycle { + case idle + case hovering +} + +private struct PaneDropPlaceholderOverlay: View { + let zone: DropZone? + let size: CGSize + + private let placeholderColor = Color.accentColor.opacity(0.25) + private let borderColor = Color.accentColor + private let padding: CGFloat = 4 + + var body: some View { + let frame = overlayFrame(for: zone, in: size) + + RoundedRectangle(cornerRadius: 8) + .fill(placeholderColor) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(borderColor, lineWidth: 2) + ) + .frame(width: frame.width, height: frame.height) + .offset(x: frame.minX, y: frame.minY) + .opacity(zone != nil ? 1 : 0) + .animation(.spring(duration: 0.25, bounce: 0.15), value: zone) + } + + private func overlayFrame(for zone: DropZone?, in size: CGSize) -> CGRect { + switch zone { + case .center, .none: + return CGRect( + x: padding, + y: padding, + width: size.width - padding * 2, + height: size.height - padding * 2 + ) + case .left: + return CGRect( + x: padding, + y: padding, + width: size.width / 2 - padding, + height: size.height - padding * 2 + ) + case .right: + return CGRect( + x: size.width / 2, + y: padding, + width: size.width / 2 - padding, + height: size.height - padding * 2 + ) + case .top: + return CGRect( + x: padding, + y: padding, + width: size.width - padding * 2, + height: size.height / 2 - padding + ) + case .bottom: + return CGRect( + x: padding, + y: size.height / 2, + width: size.width - padding * 2, + height: size.height / 2 - padding + ) + } + } +} + +struct PaneDropInteractionContainer: View { + let activeDropZone: DropZone? + let content: Content + let dropLayer: (CGSize) -> DropLayer + + init( + activeDropZone: DropZone?, + @ViewBuilder content: () -> Content, + @ViewBuilder dropLayer: @escaping (CGSize) -> DropLayer + ) { + self.activeDropZone = activeDropZone + self.content = content() + self.dropLayer = dropLayer + } + + var body: some View { + GeometryReader { geometry in + let size = geometry.size + + content + .frame(width: size.width, height: size.height) + .overlay { + dropLayer(size) + } + .overlay(alignment: .topLeading) { + PaneDropPlaceholderOverlay(zone: activeDropZone, size: size) + .allowsHitTesting(false) + } + } + .clipped() + } +} + +/// Container for a single pane with its tab bar and content area +struct PaneContainerView: View { + @Environment(BonsplitController.self) private var bonsplitController + + @Bindable var pane: PaneState + @Bindable var controller: SplitViewController + let contentBuilder: (TabItem, PaneID) -> Content + let emptyPaneBuilder: (PaneID) -> EmptyContent + var showSplitButtons: Bool = true + var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch + + @State private var activeDropZone: DropZone? + @State private var dropLifecycle: PaneDropLifecycle = .idle + + private var isFocused: Bool { + controller.focusedPaneId == pane.id + } + + private var isTabDragActive: Bool { + controller.draggingTab != nil || controller.activeDragTab != nil + } + + var body: some View { + VStack(spacing: 0) { + // Tab bar + TabBarView( + pane: pane, + isFocused: isFocused, + showSplitButtons: showSplitButtons + ) + + // Content area with drop zones + contentAreaWithDropZones + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + // Clear drop state when drag ends elsewhere (cancelled, dropped in another pane, etc.) + .onChange(of: controller.draggingTab) { _, newValue in +#if DEBUG + dlog( + "pane.dragState pane=\(pane.id.id.uuidString.prefix(5)) " + + "draggingTab=\(newValue != nil ? 1 : 0) " + + "activeDragTab=\(controller.activeDragTab != nil ? 1 : 0) " + + "dropHit=\(isTabDragActive ? 1 : 0)" + ) +#endif + if newValue == nil { + activeDropZone = nil + dropLifecycle = .idle + } + } + .onChange(of: activeDropZone) { oldValue, newValue in +#if DEBUG + let oldZone = oldValue.map { String(describing: $0) } ?? "none" + let newZone = newValue.map { String(describing: $0) } ?? "none" + let selected = pane.selectedTab ?? pane.tabs.first + let icon = selected?.icon ?? "nil" + dlog( + "pane.overlayZone pane=\(pane.id.id.uuidString.prefix(5)) " + + "old=\(oldZone) new=\(newZone) selectedIcon=\(icon)" + ) +#endif + } + } + + // MARK: - Content Area with Drop Zones + + @ViewBuilder + private var contentAreaWithDropZones: some View { + PaneDropInteractionContainer(activeDropZone: activeDropZone) { + contentArea + } dropLayer: { size in + // Drop zones layer (above content, receives drops and taps) + dropZonesLayer(size: size) + } + } + + // MARK: - Content Area + + @ViewBuilder + private var contentArea: some View { + Group { + if pane.tabs.isEmpty { + emptyPaneView + } else { + switch contentViewLifecycle { + case .recreateOnSwitch: + // Original behavior: only render selected tab + // + // `selectedTabId` can be transiently nil (or point at a tab that is being moved/closed) + // during rapid split/tab mutations. Rendering nothing for a single SwiftUI update causes + // a visible blank flash. If we have tabs, always render a stable fallback. + if let selectedTab = pane.selectedTab ?? pane.tabs.first { + contentBuilder(selectedTab, pane.id) + .frame(maxWidth: .infinity, maxHeight: .infinity) + // When the content is an NSViewRepresentable (e.g. WKWebView), it can + // sit above SwiftUI overlays and swallow drop events. During tab drags, + // disable hit testing for the content so our dropZonesLayer reliably + // receives the drag/drop interaction. + .allowsHitTesting(!isTabDragActive) + // Tab selection is often driven by `withAnimation` in the tab bar; + // don't crossfade the content when switching tabs. + .transition(.identity) + .transaction { tx in + tx.animation = nil + } + } + + case .keepAllAlive: + // macOS-like behavior: keep all tab views in hierarchy + let effectiveSelectedTabId = pane.selectedTabId ?? pane.tabs.first?.id + ZStack { + ForEach(pane.tabs) { tab in + contentBuilder(tab, pane.id) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .opacity(tab.id == effectiveSelectedTabId ? 1 : 0) + .allowsHitTesting(!isTabDragActive && tab.id == effectiveSelectedTabId) + } + } + // Prevent SwiftUI from animating Metal-backed views during tab moves. + // This avoids blank content when GhosttyKit terminals are snapshotted. + .transaction { tx in + tx.disablesAnimations = true + } + } + } + } + // Ensure a tab switch doesn't implicitly animate other animatable properties in this subtree. + .animation(nil, value: pane.selectedTabId) + // Expose the active drop zone to portal-hosted content so it can render + // its own overlay above the AppKit surface. + .environment(\.paneDropZone, activeDropZone) + } + + // MARK: - Drop Zones Layer + + @ViewBuilder + private func dropZonesLayer(size: CGSize) -> some View { + // Keep tap-to-focus and drag-drop routing as separate layers. + // + // Why: SwiftUI state propagation for `isTabDragActive` can lag behind the + // actual AppKit drag lifecycle (especially over portal-hosted terminals), + // causing a drag to start while this view is still non-hit-testable. + // The drop layer therefore stays always available for `.tabTransfer`. + ZStack { + Color.clear + .onTapGesture { +#if DEBUG + dlog("pane.focus pane=\(pane.id.id.uuidString.prefix(5))") +#endif + controller.focusPane(pane.id) + } + .allowsHitTesting(!isTabDragActive) + + Color.clear + .onDrop(of: [.tabTransfer], delegate: UnifiedPaneDropDelegate( + size: size, + pane: pane, + controller: controller, + bonsplitController: bonsplitController, + activeDropZone: $activeDropZone, + dropLifecycle: $dropLifecycle + )) + } + } + + // MARK: - Empty Pane View + + @ViewBuilder + private var emptyPaneView: some View { + emptyPaneBuilder(pane.id) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Unified Pane Drop Delegate + +struct UnifiedPaneDropDelegate: DropDelegate { + let size: CGSize + let pane: PaneState + let controller: SplitViewController + let bonsplitController: BonsplitController + @Binding var activeDropZone: DropZone? + @Binding var dropLifecycle: PaneDropLifecycle + + // Calculate zone based on position within the view + private func zoneForLocation(_ location: CGPoint) -> DropZone { + let edgeRatio: CGFloat = 0.25 + let horizontalEdge = max(80, size.width * edgeRatio) + let verticalEdge = max(80, size.height * edgeRatio) + + // Check edges first (left/right take priority at corners) + if location.x < horizontalEdge { + return .left + } else if location.x > size.width - horizontalEdge { + return .right + } else if location.y < verticalEdge { + return .top + } else if location.y > size.height - verticalEdge { + return .bottom + } else { + return .center + } + } + + private func effectiveZone(for info: DropInfo) -> DropZone { + let defaultZone = zoneForLocation(info.location) + guard let draggedTab = controller.activeDragTab ?? controller.draggingTab, + let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else { + return defaultZone + } + guard let adjacentPaneMoveZone = adjacentPaneMoveZone( + for: draggedTab, + sourcePaneId: sourcePaneId, + defaultZone: defaultZone + ) else { + return defaultZone + } + return adjacentPaneMoveZone + } + + private func adjacentPaneMoveZone( + for draggedTab: TabItem, + sourcePaneId: PaneID, + defaultZone: DropZone + ) -> DropZone? { + guard draggedTab.kind == "terminal", + sourcePaneId != pane.id else { + return nil + } + if defaultZone == .left, + bonsplitController.adjacentPane(to: sourcePaneId, direction: .right) == pane.id { + // Preserve the outer edge as a split affordance while treating the shared edge + // between adjacent panes as "drop into this pane". + return .center + } + if defaultZone == .right, + bonsplitController.adjacentPane(to: sourcePaneId, direction: .left) == pane.id { + return .center + } + return nil + } + + func performDrop(info: DropInfo) -> Bool { + if !Thread.isMainThread { + return DispatchQueue.main.sync { + performDrop(info: info) + } + } + + let zone = effectiveZone(for: info) +#if DEBUG + dlog( + "pane.drop pane=\(pane.id.id.uuidString.prefix(5)) zone=\(zone) " + + "source=\(controller.dragSourcePaneId?.id.uuidString.prefix(5) ?? "nil") " + + "hasDrag=\(controller.draggingTab != nil ? 1 : 0) " + + "hasActive=\(controller.activeDragTab != nil ? 1 : 0)" + ) +#endif + + // Read from non-observable drag state — @Observable writes from createItemProvider + // may not have propagated yet when performDrop runs. + guard let draggedTab = controller.activeDragTab ?? controller.draggingTab, + let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else { + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess else { + return false + } + let destination: BonsplitController.ExternalTabDropRequest.Destination + if zone == .center { + destination = .insert(targetPane: pane.id, targetIndex: nil) + } else if let orientation = zone.orientation { + destination = .split( + targetPane: pane.id, + orientation: orientation, + insertFirst: zone.insertsFirst + ) + } else { + return false + } + + let request = BonsplitController.ExternalTabDropRequest( + tabId: TabID(id: transfer.tab.id), + sourcePaneId: PaneID(id: transfer.sourcePaneId), + destination: destination + ) + let handled = bonsplitController.onExternalTabDrop?(request) ?? false + if handled { + dropLifecycle = .idle + activeDropZone = nil + } + return handled + } + + // Clear both observable and non-observable drag state. + dropLifecycle = .idle + activeDropZone = nil + controller.draggingTab = nil + controller.dragSourcePaneId = nil + controller.activeDragTab = nil + controller.activeDragSourcePaneId = nil + + if zone == .center { + if sourcePaneId != pane.id { + withTransaction(Transaction(animation: nil)) { + _ = bonsplitController.moveTab( + TabID(id: draggedTab.id), + toPane: pane.id, + atIndex: nil + ) + } + } + } else if let orientation = zone.orientation { +#if DEBUG + dlog( + "pane.drop.splitRequest targetPane=\(pane.id.id.uuidString.prefix(5)) " + + "sourcePane=\(sourcePaneId.id.uuidString.prefix(5)) zone=\(zone) " + + "orientation=\(orientation) insertFirst=\(zone.insertsFirst ? 1 : 0) " + + "draggedTab=\(draggedTab.id.uuidString.prefix(5))" + ) +#endif + let newPaneId = bonsplitController.splitPane( + pane.id, + orientation: orientation, + movingTab: TabID(id: draggedTab.id), + insertFirst: zone.insertsFirst + ) +#if DEBUG + dlog( + "pane.drop.splitResult targetPane=\(pane.id.id.uuidString.prefix(5)) " + + "newPane=\(newPaneId?.id.uuidString.prefix(5) ?? "nil")" + ) +#endif + } + + return true + } + + func dropEntered(info: DropInfo) { + dropLifecycle = .hovering + let zone = effectiveZone(for: info) + activeDropZone = zone +#if DEBUG + dlog( + "pane.dropEntered pane=\(pane.id.id.uuidString.prefix(5)) zone=\(zone) " + + "hasDrag=\(controller.draggingTab != nil ? 1 : 0) " + + "hasActive=\(controller.activeDragTab != nil ? 1 : 0)" + ) +#endif + } + + func dropExited(info: DropInfo) { + dropLifecycle = .idle + activeDropZone = nil +#if DEBUG + dlog("pane.dropExited pane=\(pane.id.id.uuidString.prefix(5))") +#endif + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + // Guard against dropUpdated firing after performDrop/dropExited + guard dropLifecycle == .hovering else { +#if DEBUG + dlog("pane.dropUpdated.skip pane=\(pane.id.id.uuidString.prefix(5)) reason=lifecycle_idle") +#endif + return DropProposal(operation: .move) + } + let zone = effectiveZone(for: info) + activeDropZone = zone +#if DEBUG + dlog("pane.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) zone=\(zone)") +#endif + return DropProposal(operation: .move) + } + + func validateDrop(info: DropInfo) -> Bool { + // Reject drops on inactive workspaces whose views are kept alive in a ZStack. + guard controller.isInteractive else { +#if DEBUG + dlog("pane.validateDrop pane=\(pane.id.id.uuidString.prefix(5)) allowed=0 reason=inactive") +#endif + return false + } + // The custom UTType alone is sufficient — only Bonsplit tab drags produce it. + // Do NOT gate on draggingTab != nil: @Observable changes from createItemProvider + // may not have propagated to the drop delegate yet, causing false rejections. + let hasType = info.hasItemsConforming(to: [.tabTransfer]) + guard hasType else { return false } + + // Local drags use in-memory state and are always same-process. + if controller.activeDragTab != nil || controller.draggingTab != nil { + return true + } + + // External drags (another Bonsplit controller) must include a payload from this process. + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess else { + return false + } +#if DEBUG + let hasDrag = controller.draggingTab != nil + let hasActive = controller.activeDragTab != nil + dlog( + "pane.validateDrop pane=\(pane.id.id.uuidString.prefix(5)) " + + "allowed=\(hasType ? 1 : 0) hasDrag=\(hasDrag ? 1 : 0) hasActive=\(hasActive ? 1 : 0)" + ) +#endif + return true + } + + private func decodeTransfer(from string: String) -> TabTransferData? { + guard let data = string.data(using: .utf8), + let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) else { + return nil + } + return transfer + } + + private func decodeTransfer(from info: DropInfo) -> TabTransferData? { + let pasteboard = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType(UTType.tabTransfer.identifier) + if let data = pasteboard.data(forType: type), + let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) { + return transfer + } + if let raw = pasteboard.string(forType: type) { + return decodeTransfer(from: raw) + } + return nil + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitContainerView.swift new file mode 100644 index 00000000000..1de6338e86c --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitContainerView.swift @@ -0,0 +1,907 @@ +import SwiftUI +import AppKit + +private var splitContainerProgrammaticSyncDepth = 0 + +private class ThemedSplitView: NSSplitView { + var customDividerColor: NSColor? + + override var dividerColor: NSColor { + customDividerColor ?? super.dividerColor + } + + override var isOpaque: Bool { false } +} + +#if DEBUG +private func debugPointString(_ point: NSPoint) -> String { + let x = Int(point.x.rounded()) + let y = Int(point.y.rounded()) + return "\(x)x\(y)" +} + +private func debugRectString(_ rect: NSRect) -> String { + let x = Int(rect.origin.x.rounded()) + let y = Int(rect.origin.y.rounded()) + let w = Int(rect.size.width.rounded()) + let h = Int(rect.size.height.rounded()) + return "\(x):\(y)+\(w)x\(h)" +} + +private final class DebugSplitView: ThemedSplitView { + var debugSplitToken: String = "none" + private var lastLoggedEventTimestampMs: Int = -1 + + override func hitTest(_ point: NSPoint) -> NSView? { + let result = super.hitTest(point) + guard let event = NSApp.currentEvent else { return result } + guard event.type == .leftMouseDown else { return result } + guard event.window == window else { return result } + let eventTimestampMs = Int((event.timestamp * 1000).rounded()) + guard eventTimestampMs != lastLoggedEventTimestampMs else { return result } + lastLoggedEventTimestampMs = eventTimestampMs + + let dividerRect = debugDividerRect() + let hitRect = dividerRect?.insetBy(dx: -4, dy: -4) + let onDivider = dividerRect?.contains(point) == true + let nearDivider = hitRect?.contains(point) == true + let targetClass = result.map { NSStringFromClass(type(of: $0)) } ?? "nil" + + dlog( + "divider.hitTest split=\(debugSplitToken) point=\(debugPointString(point)) target=\(targetClass) onDivider=\(onDivider ? 1 : 0) nearDivider=\(nearDivider ? 1 : 0)" + ) + + return result + } + + private func debugDividerRect() -> NSRect? { + guard arrangedSubviews.count >= 2 else { return nil } + + let a = arrangedSubviews[0].frame + let b = arrangedSubviews[1].frame + let thickness = dividerThickness + + if isVertical { + guard a.width > 1, b.width > 1 else { return nil } + let x = max(0, a.maxX) + return NSRect(x: x, y: 0, width: thickness, height: bounds.height) + } + + guard a.height > 1, b.height > 1 else { return nil } + let y = max(0, a.maxY) + return NSRect(x: 0, y: y, width: bounds.width, height: thickness) + } +} +#endif + +/// SwiftUI wrapper around NSSplitView for native split behavior +struct SplitContainerView: NSViewRepresentable { + @Bindable var splitState: SplitState + let controller: SplitViewController + let appearance: BonsplitConfiguration.Appearance + let contentBuilder: (TabItem, PaneID) -> Content + let emptyPaneBuilder: (PaneID) -> EmptyContent + var showSplitButtons: Bool = true + var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch + /// Callback when geometry changes. Bool indicates if change is during active divider drag. + var onGeometryChange: ((_ isDragging: Bool) -> Void)? + /// Animation configuration + var enableAnimations: Bool = true + var animationDuration: Double = 0.15 + + func makeCoordinator() -> Coordinator { + Coordinator( + splitState: splitState, + minimumPaneWidth: appearance.minimumPaneWidth, + minimumPaneHeight: appearance.minimumPaneHeight, + onGeometryChange: onGeometryChange + ) + } + + func makeNSView(context: Context) -> NSSplitView { +#if DEBUG + let splitView: ThemedSplitView = { + let debugSplitView = DebugSplitView() + debugSplitView.debugSplitToken = String(splitState.id.uuidString.prefix(5)) + return debugSplitView + }() +#else + let splitView = ThemedSplitView() +#endif + splitView.customDividerColor = TabBarColors.nsColorSeparator(for: appearance) + splitView.isVertical = splitState.orientation == .horizontal + splitView.dividerStyle = .thin + splitView.delegate = context.coordinator + splitView.wantsLayer = true + splitView.layer?.backgroundColor = NSColor.clear.cgColor + splitView.layer?.isOpaque = false + + // Keep arranged subviews stable (always 2) to avoid transient "collapse" flashes when + // replacing pane<->split content. We swap the hosted content within these containers. + let firstContainer = NSView() + firstContainer.wantsLayer = true + firstContainer.layer?.backgroundColor = NSColor.clear.cgColor + firstContainer.layer?.isOpaque = false + firstContainer.layer?.masksToBounds = true + let firstController = makeHostingController(for: splitState.first) + installHostingController(firstController, into: firstContainer) + splitView.addArrangedSubview(firstContainer) + context.coordinator.firstHostingController = firstController + + let secondContainer = NSView() + secondContainer.wantsLayer = true + secondContainer.layer?.backgroundColor = NSColor.clear.cgColor + secondContainer.layer?.isOpaque = false + secondContainer.layer?.masksToBounds = true + let secondController = makeHostingController(for: splitState.second) + installHostingController(secondController, into: secondContainer) + splitView.addArrangedSubview(secondContainer) + context.coordinator.secondHostingController = secondController + + context.coordinator.splitView = splitView + + // Capture animation origin before it gets cleared + let animationOrigin = splitState.animationOrigin +#if DEBUG + let splitDebugToken = String(splitState.id.uuidString.prefix(5)) + let orientationToken = splitState.orientation == .horizontal ? "horizontal" : "vertical" + let animationOriginToken: String = { + guard let animationOrigin else { return "none" } + switch animationOrigin { + case .fromFirst: return "fromFirst" + case .fromSecond: return "fromSecond" + } + }() +#endif + + // Determine which pane is new (will be hidden initially) + let newPaneIndex = animationOrigin == .fromFirst ? 0 : 1 + + // Capture animation settings for async block + let shouldAnimate = enableAnimations && animationOrigin != nil + let duration = animationDuration + + if animationOrigin != nil { + // Clear immediately so we don't re-animate on updates + splitState.animationOrigin = nil + + if shouldAnimate { + // Hide the NEW pane immediately to prevent flash + splitView.arrangedSubviews[newPaneIndex].isHidden = true + + // Track that we're animating (skip delegate position updates) + context.coordinator.isAnimating = true + } + } + + // Apply the initial divider position once after initial layout scheduling. + func applyInitialDividerPosition() { + if context.coordinator.didApplyInitialDividerPosition { + return + } + + let totalSize = splitState.orientation == .horizontal + ? splitView.bounds.width + : splitView.bounds.height + let availableSize = max(totalSize - splitView.dividerThickness, 0) + + guard availableSize > 0 else { + // makeNSView can run before NSSplitView has a real frame; retry on the + // next runloop so we still get the intended entry animation. + context.coordinator.initialDividerApplyAttempts += 1 +#if DEBUG + let attempt = context.coordinator.initialDividerApplyAttempts + if attempt == 1 || attempt == 4 || attempt == 8 || attempt == 12 { + dlog( + "split.entry.wait split=\(splitDebugToken) orientation=\(orientationToken) " + + "origin=\(animationOriginToken) animate=\(shouldAnimate ? 1 : 0) " + + "attempt=\(attempt) total=\(Int(totalSize.rounded())) available=\(Int(availableSize.rounded()))" + ) + } +#endif + if context.coordinator.initialDividerApplyAttempts < 12 { + DispatchQueue.main.async { + applyInitialDividerPosition() + } + return + } + + // Safety fallback: don't leave the new pane hidden forever. + context.coordinator.didApplyInitialDividerPosition = true + if animationOrigin != nil, shouldAnimate { + splitView.arrangedSubviews[newPaneIndex].isHidden = false + context.coordinator.isAnimating = false + } +#if DEBUG + dlog( + "split.entry.fallback split=\(splitDebugToken) orientation=\(orientationToken) " + + "origin=\(animationOriginToken) animate=\(shouldAnimate ? 1 : 0) attempts=\(context.coordinator.initialDividerApplyAttempts)" + ) +#endif + return + } + + context.coordinator.didApplyInitialDividerPosition = true + context.coordinator.initialDividerApplyAttempts = 0 + + if animationOrigin != nil { + let targetPosition = availableSize * 0.5 + splitState.dividerPosition = 0.5 + + if shouldAnimate { + // Position at edge while new pane is hidden + let startPosition: CGFloat = animationOrigin == .fromFirst ? 0 : availableSize +#if DEBUG + dlog( + "split.entry.start split=\(splitDebugToken) orientation=\(orientationToken) " + + "origin=\(animationOriginToken) newPaneIndex=\(newPaneIndex) " + + "startPx=\(Int(startPosition.rounded())) targetPx=\(Int(targetPosition.rounded())) " + + "available=\(Int(availableSize.rounded()))" + ) +#endif + context.coordinator.setPositionSafely(startPosition, in: splitView, layout: true) + + // Wait for layout + DispatchQueue.main.async { + // Show the new pane and animate + splitView.arrangedSubviews[newPaneIndex].isHidden = false + + SplitAnimator.shared.animate( + splitView: splitView, + from: startPosition, + to: targetPosition, + duration: duration + ) { + context.coordinator.isAnimating = false + // Re-assert exact 0.5 ratio to prevent pixel-rounding drift + splitState.dividerPosition = 0.5 + context.coordinator.lastAppliedPosition = 0.5 +#if DEBUG + dlog( + "split.entry.complete split=\(splitDebugToken) orientation=\(orientationToken) " + + "origin=\(animationOriginToken) finalRatio=\(String(format: "%.3f", splitState.dividerPosition))" + ) +#endif + } + } + } else { + // No animation - just set the position immediately + context.coordinator.setPositionSafely(targetPosition, in: splitView, layout: false) +#if DEBUG + dlog( + "split.entry.noAnimation split=\(splitDebugToken) orientation=\(orientationToken) " + + "origin=\(animationOriginToken) targetPx=\(Int(targetPosition.rounded())) " + + "enableAnimations=\(enableAnimations ? 1 : 0)" + ) +#endif + } + } else { + // No animation - just set the position + let position = availableSize * splitState.dividerPosition + context.coordinator.setPositionSafely(position, in: splitView, layout: false) + } + } + + DispatchQueue.main.async { + applyInitialDividerPosition() + } + + return splitView + } + + func updateNSView(_ splitView: NSSplitView, context: Context) { + // SwiftUI may reuse the same NSSplitView/Coordinator instance while the underlying SplitState + // object changes (e.g., during split tree restructuring). Keep the coordinator pointed at + // the latest state to avoid syncing geometry against a stale model. + context.coordinator.update( + splitState: splitState, + minimumPaneWidth: appearance.minimumPaneWidth, + minimumPaneHeight: appearance.minimumPaneHeight, + onGeometryChange: onGeometryChange + ) + + // Hide the NSSplitView when inactive so AppKit's drag routing doesn't deliver + // drag sessions to views belonging to background workspaces. SwiftUI's + // .allowsHitTesting(false) only affects gesture recognizers, not AppKit's + // view-hierarchy-based NSDraggingDestination routing. + splitView.isHidden = !controller.isInteractive + splitView.wantsLayer = true + splitView.layer?.backgroundColor = NSColor.clear.cgColor + splitView.layer?.isOpaque = false + (splitView as? ThemedSplitView)?.customDividerColor = TabBarColors.nsColorSeparator(for: appearance) + + // Update orientation if changed + splitView.isVertical = splitState.orientation == .horizontal + + // Update children. When a child's node type changes (split→pane or pane→split), + // replace the hosted content (not the arranged subview) to ensure native NSViews + // (e.g., Metal-backed terminals) are properly moved through the AppKit hierarchy + // without briefly dropping arrangedSubviews to 1. + let arranged = splitView.arrangedSubviews + if arranged.count >= 2 { + let firstType = splitState.first.nodeType + let secondType = splitState.second.nodeType + + let firstContainer = arranged[0] + let secondContainer = arranged[1] + firstContainer.wantsLayer = true + firstContainer.layer?.backgroundColor = NSColor.clear.cgColor + firstContainer.layer?.isOpaque = false + secondContainer.wantsLayer = true + secondContainer.layer?.backgroundColor = NSColor.clear.cgColor + secondContainer.layer?.isOpaque = false + + updateHostedContent( + in: firstContainer, + node: splitState.first, + nodeTypeChanged: firstType != context.coordinator.firstNodeType, + controller: &context.coordinator.firstHostingController + ) + context.coordinator.firstNodeType = firstType + + updateHostedContent( + in: secondContainer, + node: splitState.second, + nodeTypeChanged: secondType != context.coordinator.secondNodeType, + controller: &context.coordinator.secondHostingController + ) + context.coordinator.secondNodeType = secondType + } + + // Access dividerPosition to ensure SwiftUI tracks this dependency + // Then sync if the position changed externally + let currentPosition = splitState.dividerPosition + context.coordinator.syncPosition(currentPosition, in: splitView) + } + + // MARK: - Helpers + + private func makeHostingController(for node: SplitNode) -> NSHostingController { + let hostingController = NSHostingController(rootView: AnyView(makeView(for: node))) + if #available(macOS 13.0, *) { + // NSSplitView owns pane geometry. Keep NSHostingController from publishing + // intrinsic-size constraints that force a minimum pane width. + hostingController.sizingOptions = [] + } + + let hostedView = hostingController.view + // NSSplitView lays out arranged subviews by setting frames. Leaving Auto Layout + // enabled on these NSHostingViews can allow them to compress to 0 during + // structural updates, collapsing panes. + hostedView.translatesAutoresizingMaskIntoConstraints = true + hostedView.autoresizingMask = [.width, .height] + // Do not let SwiftUI intrinsic size push split panes wider than the model frame. + let relaxed = NSLayoutConstraint.Priority(1) + hostedView.setContentHuggingPriority(relaxed, for: .horizontal) + hostedView.setContentCompressionResistancePriority(relaxed, for: .horizontal) + hostedView.setContentHuggingPriority(relaxed, for: .vertical) + hostedView.setContentCompressionResistancePriority(relaxed, for: .vertical) + return hostingController + } + + private func installHostingController(_ hostingController: NSHostingController, into container: NSView) { + let hostedView = hostingController.view + hostedView.frame = container.bounds + hostedView.autoresizingMask = [.width, .height] + if hostedView.superview !== container { + container.addSubview(hostedView) + } + } + + private func updateHostedContent( + in container: NSView, + node: SplitNode, + nodeTypeChanged: Bool, + controller: inout NSHostingController? + ) { + // Historically we recreated the NSHostingController when the child node type changed + // (pane <-> split) to force a full detach/reattach of native AppKit subviews. + // + // In practice, that can introduce a single-frame "blank flash" for Metal/IOSurface-backed + // content during split collapse (SwiftUI tears down the old subtree before the new subtree + // has produced its native backing views). + // + // Keeping the hosting controller stable and just swapping its rootView makes the update + // atomic from AppKit's perspective and avoids the transient blank frame. + _ = nodeTypeChanged // keep signature; behavior is intentionally identical either way. + + if let current = controller { + current.rootView = AnyView(makeView(for: node)) + // Ensure fill if container bounds changed without a layout pass yet. + current.view.frame = container.bounds + return + } + + let newController = makeHostingController(for: node) + installHostingController(newController, into: container) + controller = newController + } + + @ViewBuilder + private func makeView(for node: SplitNode) -> some View { + switch node { + case .pane(let paneState): + PaneContainerView( + pane: paneState, + controller: controller, + contentBuilder: contentBuilder, + emptyPaneBuilder: emptyPaneBuilder, + showSplitButtons: showSplitButtons, + contentViewLifecycle: contentViewLifecycle + ) + case .split(let nestedSplitState): + SplitContainerView( + splitState: nestedSplitState, + controller: controller, + appearance: appearance, + contentBuilder: contentBuilder, + emptyPaneBuilder: emptyPaneBuilder, + showSplitButtons: showSplitButtons, + contentViewLifecycle: contentViewLifecycle, + onGeometryChange: onGeometryChange, + enableAnimations: enableAnimations, + animationDuration: animationDuration + ) + } + } + + // MARK: - Coordinator + + class Coordinator: NSObject, NSSplitViewDelegate { + var splitState: SplitState + private var splitStateId: UUID + private var minimumPaneWidth: CGFloat + private var minimumPaneHeight: CGFloat + weak var splitView: NSSplitView? + var isAnimating = false + var didApplyInitialDividerPosition = false + /// Initial divider placement can run before NSSplitView has a real size. + /// Retry a few turns so entry animations are not dropped on first layout. + var initialDividerApplyAttempts = 0 + var onGeometryChange: ((_ isDragging: Bool) -> Void)? + /// Track last applied position to detect external changes + var lastAppliedPosition: CGFloat = 0.5 + // Guard programmatic `setPosition` re-entrancy from resize callbacks. + var isSyncingProgrammatically = false + /// Track if user is actively dragging the divider + var isDragging = false + /// Track child node types to detect structural changes + var firstNodeType: SplitNode.NodeType + var secondNodeType: SplitNode.NodeType + /// Retain hosting controllers so SwiftUI content stays alive + var firstHostingController: NSHostingController? + var secondHostingController: NSHostingController? + + init( + splitState: SplitState, + minimumPaneWidth: CGFloat, + minimumPaneHeight: CGFloat, + onGeometryChange: ((_ isDragging: Bool) -> Void)? + ) { + self.splitState = splitState + self.splitStateId = splitState.id + self.minimumPaneWidth = minimumPaneWidth + self.minimumPaneHeight = minimumPaneHeight + self.onGeometryChange = onGeometryChange + self.lastAppliedPosition = splitState.dividerPosition + self.firstNodeType = splitState.first.nodeType + self.secondNodeType = splitState.second.nodeType + } + + func update( + splitState newState: SplitState, + minimumPaneWidth: CGFloat, + minimumPaneHeight: CGFloat, + onGeometryChange: ((_ isDragging: Bool) -> Void)? + ) { + self.onGeometryChange = onGeometryChange + self.minimumPaneWidth = minimumPaneWidth + self.minimumPaneHeight = minimumPaneHeight + + // If SwiftUI reused this representable for a different split node, + // reset our cached sync state so we don't "pin" the divider to an edge. + if newState.id != splitStateId { + splitStateId = newState.id + splitState = newState + lastAppliedPosition = newState.dividerPosition + didApplyInitialDividerPosition = false + initialDividerApplyAttempts = 0 + isAnimating = false + isDragging = false + firstNodeType = newState.first.nodeType + secondNodeType = newState.second.nodeType + return + } + + // Same split node; keep reference updated anyway. + splitState = newState + } + + private func splitTotalSize(in splitView: NSSplitView) -> CGFloat { + splitState.orientation == .horizontal + ? splitView.bounds.width + : splitView.bounds.height + } + + private func splitAvailableSize(in splitView: NSSplitView) -> CGFloat { + max(splitTotalSize(in: splitView) - splitView.dividerThickness, 0) + } + + private func requestedMinimumPaneSize() -> CGFloat { + max( + splitState.orientation == .horizontal ? minimumPaneWidth : minimumPaneHeight, + 1 + ) + } + + private func effectiveMinimumPaneSize(in splitView: NSSplitView) -> CGFloat { + let available = splitAvailableSize(in: splitView) + guard available > 0 else { return 0 } + // When the container is too small for both configured minimums, keep both panes + // visible by evenly splitting the available space rather than forcing invalid bounds. + return min(requestedMinimumPaneSize(), available / 2) + } + + private func normalizedDividerBounds(in splitView: NSSplitView) -> ClosedRange { + let available = splitAvailableSize(in: splitView) + guard available > 0 else { return 0...1 } + let minNormalized = min(0.5, effectiveMinimumPaneSize(in: splitView) / available) + return minNormalized...(1 - minNormalized) + } + + private func clampedDividerPosition(_ position: CGFloat, in splitView: NSSplitView) -> CGFloat { + let available = splitAvailableSize(in: splitView) + guard available > 0 else { return 0 } + let minPaneSize = effectiveMinimumPaneSize(in: splitView) + let maxPosition = max(minPaneSize, available - minPaneSize) + return min(max(position, minPaneSize), maxPosition) + } + + private func dividerHitRectContains(_ point: NSPoint, rect: NSRect) -> Bool { + point.x >= rect.minX && + point.x <= rect.maxX && + point.y >= rect.minY && + point.y <= rect.maxY + } +#if DEBUG + private func debugLogDividerDragSkip( + _ reason: String, + splitView: NSSplitView, + event: NSEvent? = nil, + location: NSPoint? = nil, + dividerRect: NSRect? = nil, + hitRect: NSRect? = nil + ) { + var message = "divider.dragCheck.skip split=\(splitState.id.uuidString.prefix(5)) reason=\(reason)" + if let event { + let ageMs = Int(((ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000).rounded()) + message += " eventType=\(event.type.rawValue) ageMs=\(ageMs)" + } else { + message += " event=nil" + } + message += " splitWin=\(splitView.window?.windowNumber ?? -1)" + if let location { + message += " loc=\(debugPointString(location))" + } + if let dividerRect { + message += " divider=\(debugRectString(dividerRect))" + } + if let hitRect { + message += " hit=\(debugRectString(hitRect))" + } + dlog(message) + } +#endif + /// Apply external position changes to the NSSplitView + func setPositionSafely(_ position: CGFloat, in splitView: NSSplitView, layout: Bool = true) { + isSyncingProgrammatically = true + splitContainerProgrammaticSyncDepth += 1 + defer { + isSyncingProgrammatically = false + splitContainerProgrammaticSyncDepth = max(0, splitContainerProgrammaticSyncDepth - 1) + } + let clampedPosition = clampedDividerPosition(position, in: splitView) + splitView.setPosition(clampedPosition, ofDividerAt: 0) + if layout { + splitView.layoutSubtreeIfNeeded() + } + } + + func syncPosition(_ statePosition: CGFloat, in splitView: NSSplitView) { + guard !isAnimating else { return } + guard !isSyncingProgrammatically else { return } + guard splitContainerProgrammaticSyncDepth == 0 else { return } + + guard splitView.arrangedSubviews.count >= 2 else { + // Structural updates can temporarily remove an arranged subview. + // A subsequent update/layout pass will re-apply the model position. +#if DEBUG + BonsplitDebugCounters.recordArrangedSubviewUnderflow() +#endif + return + } + + let availableSize = splitAvailableSize(in: splitView) + + // During view reparenting, NSSplitView can briefly report 0-sized bounds. + // A later layout pass with real bounds will apply the model ratio. + guard availableSize > 0 else { return } + let stateBounds = normalizedDividerBounds(in: splitView) + let clampedStatePosition = max( + stateBounds.lowerBound, + min(stateBounds.upperBound, statePosition) + ) + + // Keep the view in sync even if the model hasn't changed. Structural updates (pane↔split) + // can temporarily reset divider positions; lastAppliedPosition alone isn't enough. + let currentDividerPixels: CGFloat = { + let firstSubview = splitView.arrangedSubviews[0] + return splitState.orientation == .horizontal ? firstSubview.frame.width : firstSubview.frame.height + }() + let currentNormalized = max( + stateBounds.lowerBound, + min(stateBounds.upperBound, currentDividerPixels / availableSize) + ) + + if abs(clampedStatePosition - lastAppliedPosition) <= 0.01 && + abs(currentNormalized - clampedStatePosition) <= 0.01 { + return + } + + let pixelPosition = availableSize * clampedStatePosition + setPositionSafely(pixelPosition, in: splitView, layout: true) + lastAppliedPosition = clampedStatePosition + } + + func splitViewWillResizeSubviews(_ notification: Notification) { + guard let splitView = notification.object as? NSSplitView else { return } + // If the left mouse button isn't down, this can't be an interactive divider drag. + // (`splitViewWillResizeSubviews` can fire for programmatic/layout-driven resizes too.) + guard (NSEvent.pressedMouseButtons & 1) != 0 else { +#if DEBUG + if let event = NSApp.currentEvent, + event.type == .leftMouseDown || event.type == .leftMouseDragged { + debugLogDividerDragSkip("leftMouseNotPressed", splitView: splitView, event: event) + } +#endif + isDragging = false + return + } + + // If we're already tracking an active drag, keep the flag until mouse-up. + if isDragging { + return + } + + guard let event = NSApp.currentEvent else { +#if DEBUG + debugLogDividerDragSkip("noCurrentEvent", splitView: splitView, event: nil) +#endif + return + } + + // Only treat this as a divider drag if the pointer is actually on the divider. + // This delegate callback can also fire during window resizes or structural updates, + // and persisting divider ratios in those cases can permanently collapse a pane. + let now = ProcessInfo.processInfo.systemUptime + // `NSApp.currentEvent` can be stale when called from async UI work (e.g. socket commands). + // Only trust very recent events. + guard (now - event.timestamp) < 0.1 else { +#if DEBUG + debugLogDividerDragSkip("staleCurrentEvent", splitView: splitView, event: event) +#endif + return + } + guard event.type == .leftMouseDown || event.type == .leftMouseDragged else { +#if DEBUG + debugLogDividerDragSkip("wrongEventType", splitView: splitView, event: event) +#endif + return + } + guard event.window == splitView.window else { +#if DEBUG + debugLogDividerDragSkip("windowMismatch", splitView: splitView, event: event) +#endif + return + } + guard splitView.arrangedSubviews.count >= 2 else { +#if DEBUG + debugLogDividerDragSkip("arrangedUnderflow", splitView: splitView, event: event) +#endif + return + } + + let location = splitView.convert(event.locationInWindow, from: nil) + let a = splitView.arrangedSubviews[0].frame + let b = splitView.arrangedSubviews[1].frame + let thickness = splitView.dividerThickness + let dividerRect: NSRect + if splitView.isVertical { + // If we don't have real frames yet (during structural updates), don't infer dragging. + guard a.width > 1, b.width > 1 else { +#if DEBUG + debugLogDividerDragSkip("invalidSubviewWidths", splitView: splitView, event: event, location: location) +#endif + return + } + // Vertical divider between left/right arranged subviews. + let x = max(0, a.maxX) + dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height) + } else { + guard a.height > 1, b.height > 1 else { +#if DEBUG + debugLogDividerDragSkip("invalidSubviewHeights", splitView: splitView, event: event, location: location) +#endif + return + } + // Horizontal divider between top/bottom arranged subviews. + let y = max(0, a.maxY) + dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness) + } + // Match the divider's expanded effective rect and treat the max edge + // as inside so drag tracking doesn't miss when AppKit reports a point + // exactly on the divider boundary during multi-split resizes. + let hitRect = dividerRect.insetBy(dx: -5, dy: -5) + if dividerHitRectContains(location, rect: hitRect) { + isDragging = true +#if DEBUG + dlog( + "divider.dragStart split=\(splitState.id.uuidString.prefix(5)) loc=\(debugPointString(location)) divider=\(debugRectString(dividerRect)) hit=\(debugRectString(hitRect))" + ) +#endif + } else { +#if DEBUG + debugLogDividerDragSkip( + "hitRectMiss", + splitView: splitView, + event: event, + location: location, + dividerRect: dividerRect, + hitRect: hitRect + ) +#endif + } + } + + func splitViewDidResizeSubviews(_ notification: Notification) { + // Skip position updates during animation + guard !isAnimating else { return } + guard let splitView = notification.object as? NSSplitView else { return } +#if DEBUG + let subframes = splitView.arrangedSubviews.enumerated().map { (i, v) in + "\(i)=\(Int(v.frame.width))x\(Int(v.frame.height))" + }.joined(separator: " ") + dlog("split.didResize split=\(splitState.id.uuidString.prefix(5)) orient=\(splitState.orientation == .horizontal ? "H" : "V") container=\(Int(splitView.frame.width))x\(Int(splitView.frame.height)) subs=[\(subframes)] anim=\(isAnimating ? 1 : 0) sync=\(isSyncingProgrammatically ? 1 : 0)") +#endif + if isSyncingProgrammatically || splitContainerProgrammaticSyncDepth > 0 { + return + } + // Prevent stale drag state from persisting through programmatic/async resizes. + let leftDown = (NSEvent.pressedMouseButtons & 1) != 0 + if !leftDown { +#if DEBUG + if isDragging { + dlog("divider.dragStateReset split=\(splitState.id.uuidString.prefix(5)) reason=leftMouseReleased") + } +#endif + isDragging = false + } + // During structural updates (pane↔split), arranged subviews can be temporarily removed. + // Avoid persisting a dividerPosition derived from a transient 1-subview layout. + guard splitView.arrangedSubviews.count >= 2 else { +#if DEBUG + BonsplitDebugCounters.recordArrangedSubviewUnderflow() +#endif + return + } + + let availableSize = splitAvailableSize(in: splitView) + + guard availableSize > 0 else { return } + + if let firstSubview = splitView.arrangedSubviews.first { + let dividerPosition = splitState.orientation == .horizontal + ? firstSubview.frame.width + : firstSubview.frame.height + + var normalizedPosition = dividerPosition / availableSize + + // Never persist a fully-collapsed pane ratio. (This can happen if we ever + // see a transient 0-sized layout during a drag or structural update.) + let normalizedBounds = normalizedDividerBounds(in: splitView) + normalizedPosition = max( + normalizedBounds.lowerBound, + min(normalizedBounds.upperBound, normalizedPosition) + ) + + // Snap to 0.5 if very close (prevents pixel-rounding drift) + if abs(normalizedPosition - 0.5) < 0.01 { + normalizedPosition = 0.5 + } + + // Check if drag ended (mouse up) + let wasDragging = isDragging && leftDown + if let event = NSApp.currentEvent, event.type == .leftMouseUp { +#if DEBUG + dlog("divider.dragEnd split=\(splitState.id.uuidString.prefix(5))") +#endif + isDragging = false + } + + // Only update the model when the user is actively dragging. For other resizes + // (window resizes, view reparenting, pane↔split structural updates), the model's + // dividerPosition should remain stable; syncPosition() will keep the view aligned. + guard wasDragging else { +#if DEBUG + let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "none" + dlog( + "divider.resizeIgnored split=\(splitState.id.uuidString.prefix(5)) eventType=\(eventType) leftDown=\(leftDown ? 1 : 0) isDragging=\(isDragging ? 1 : 0) normalized=\(String(format: "%.3f", normalizedPosition)) model=\(String(format: "%.3f", self.splitState.dividerPosition))" + ) +#endif + let statePosition = self.splitState.dividerPosition + // Re-assert synchronously. setPositionSafely sets isSyncingProgrammatically=true, + // so the recursive splitViewDidResizeSubviews call is caught by the guard above. + // Deferring to the next runloop turn would allow the transient frame to propagate + // through SwiftUI layout → ghostty terminal resize → reflow, causing content shifts. + self.syncPosition(statePosition, in: splitView) + self.onGeometryChange?(false) + return + } + + // NSSplitView delegate callbacks already arrive on the main thread. + // Deferring this write through a Task can replay stale divider ratios + // via updateNSView() and make fast drags snap back to older positions. +#if DEBUG + dlog( + "divider.dragUpdate split=\(splitState.id.uuidString.prefix(5)) normalized=\(String(format: "%.3f", normalizedPosition)) px=\(Int(dividerPosition.rounded())) available=\(Int(availableSize.rounded()))" + ) +#endif + self.splitState.dividerPosition = normalizedPosition + self.lastAppliedPosition = normalizedPosition + // Notify geometry change with drag state + self.onGeometryChange?(wasDragging) + } + } + + func splitView(_ splitView: NSSplitView, effectiveRect proposedEffectiveRect: NSRect, forDrawnRect drawnRect: NSRect, ofDividerAt dividerIndex: Int) -> NSRect { + let expanded = drawnRect.insetBy(dx: -5, dy: -5) + return proposedEffectiveRect.union(expanded) + } + + func splitView(_ splitView: NSSplitView, additionalEffectiveRectOfDividerAt dividerIndex: Int) -> NSRect { + guard splitView.arrangedSubviews.count >= dividerIndex + 2 else { return .zero } + + let first = splitView.arrangedSubviews[dividerIndex].frame + let second = splitView.arrangedSubviews[dividerIndex + 1].frame + let thickness = splitView.dividerThickness + + let dividerRect: NSRect + if splitView.isVertical { + guard first.width > 1, second.width > 1 else { return .zero } + let x = max(0, first.maxX) + dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height) + } else { + guard first.height > 1, second.height > 1 else { return .zero } + let y = max(0, first.maxY) + dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness) + } + + return dividerRect.insetBy(dx: -5, dy: -5) + } + + func splitView(_ splitView: NSSplitView, constrainMinCoordinate proposedMinimumPosition: CGFloat, ofSubviewAt dividerIndex: Int) -> CGFloat { + // Allow edge positions during animation + guard !isAnimating else { return proposedMinimumPosition } + return max(proposedMinimumPosition, effectiveMinimumPaneSize(in: splitView)) + } + + func splitView(_ splitView: NSSplitView, constrainMaxCoordinate proposedMaximumPosition: CGFloat, ofSubviewAt dividerIndex: Int) -> CGFloat { + // Allow edge positions during animation + guard !isAnimating else { return proposedMaximumPosition } + let availableSize = splitAvailableSize(in: splitView) + let minimumPaneSize = effectiveMinimumPaneSize(in: splitView) + let maxCoordinate = max(minimumPaneSize, availableSize - minimumPaneSize) + return min(proposedMaximumPosition, maxCoordinate) + } + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitNodeView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitNodeView.swift new file mode 100644 index 00000000000..ea098c976c2 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitNodeView.swift @@ -0,0 +1,121 @@ +import SwiftUI +import AppKit + +/// Recursively renders a split node (pane or split) +struct SplitNodeView: View { + @Environment(SplitViewController.self) private var controller + + let node: SplitNode + let contentBuilder: (TabItem, PaneID) -> Content + let emptyPaneBuilder: (PaneID) -> EmptyContent + let appearance: BonsplitConfiguration.Appearance + var showSplitButtons: Bool = true + var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch + var onGeometryChange: ((_ isDragging: Bool) -> Void)? + var enableAnimations: Bool = true + var animationDuration: Double = 0.15 + + var body: some View { + switch node { + case .pane(let paneState): + // Wrap in NSHostingController for proper layout constraints + SinglePaneWrapper( + pane: paneState, + contentBuilder: contentBuilder, + emptyPaneBuilder: emptyPaneBuilder, + showSplitButtons: showSplitButtons, + contentViewLifecycle: contentViewLifecycle + ) + + case .split(let splitState): + SplitContainerView( + splitState: splitState, + controller: controller, + appearance: appearance, + contentBuilder: contentBuilder, + emptyPaneBuilder: emptyPaneBuilder, + showSplitButtons: showSplitButtons, + contentViewLifecycle: contentViewLifecycle, + onGeometryChange: onGeometryChange, + enableAnimations: enableAnimations, + animationDuration: animationDuration + ) + } + } +} + +/// Container NSView for a pane inside SinglePaneWrapper. +class PaneDragContainerView: NSView { + override var isOpaque: Bool { false } +} + +/// Wrapper that uses NSHostingController for proper AppKit layout constraints +struct SinglePaneWrapper: NSViewRepresentable { + @Environment(SplitViewController.self) private var controller + + let pane: PaneState + let contentBuilder: (TabItem, PaneID) -> Content + let emptyPaneBuilder: (PaneID) -> EmptyContent + var showSplitButtons: Bool = true + var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch + + func makeNSView(context: Context) -> NSView { + let paneView = PaneContainerView( + pane: pane, + controller: controller, + contentBuilder: contentBuilder, + emptyPaneBuilder: emptyPaneBuilder, + showSplitButtons: showSplitButtons, + contentViewLifecycle: contentViewLifecycle + ) + let hostingController = NSHostingController(rootView: paneView) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + + let containerView = PaneDragContainerView() + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.layer?.isOpaque = false + containerView.layer?.masksToBounds = true + containerView.addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: containerView.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) + ]) + + // Store hosting controller to keep it alive + context.coordinator.hostingController = hostingController + + return containerView + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Hide the container when inactive so AppKit's drag routing doesn't deliver + // drag sessions to views belonging to background workspaces. + nsView.isHidden = !controller.isInteractive + nsView.wantsLayer = true + nsView.layer?.backgroundColor = NSColor.clear.cgColor + nsView.layer?.isOpaque = false + nsView.layer?.masksToBounds = true + + let paneView = PaneContainerView( + pane: pane, + controller: controller, + contentBuilder: contentBuilder, + emptyPaneBuilder: emptyPaneBuilder, + showSplitButtons: showSplitButtons, + contentViewLifecycle: contentViewLifecycle + ) + context.coordinator.hostingController?.rootView = paneView + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + var hostingController: NSHostingController>? + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift new file mode 100644 index 00000000000..5c0311cd43d --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitViewContainer.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// Main container view that renders the entire split tree (internal implementation) +struct SplitViewContainer: View { + @Environment(SplitViewController.self) private var controller + + let contentBuilder: (TabItem, PaneID) -> Content + let emptyPaneBuilder: (PaneID) -> EmptyContent + let appearance: BonsplitConfiguration.Appearance + var showSplitButtons: Bool = true + var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch + var onGeometryChange: ((_ isDragging: Bool) -> Void)? + var enableAnimations: Bool = true + var animationDuration: Double = 0.15 + + var body: some View { + GeometryReader { geometry in + splitNodeContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(TabBarColors.paneBackground(for: appearance)) + .focusable() + .focusEffectDisabled() + .onChange(of: geometry.size) { _, newSize in + updateContainerFrame(geometry: geometry) + } + .onAppear { + updateContainerFrame(geometry: geometry) + } + } + } + + private func updateContainerFrame(geometry: GeometryProxy) { + // Get frame in global coordinate space + let frame = geometry.frame(in: .global) + controller.containerFrame = frame + onGeometryChange?(false) // Container resize is not a drag + } + + @ViewBuilder + private var splitNodeContent: some View { + let nodeToRender = controller.zoomedNode ?? controller.rootNode + SplitNodeView( + node: nodeToRender, + contentBuilder: contentBuilder, + emptyPaneBuilder: emptyPaneBuilder, + appearance: appearance, + showSplitButtons: showSplitButtons, + contentViewLifecycle: contentViewLifecycle, + onGeometryChange: onGeometryChange, + enableAnimations: enableAnimations, + animationDuration: animationDuration + ) + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift new file mode 100644 index 00000000000..8d083c90b82 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -0,0 +1,1325 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers + +private struct SelectedTabFramePreferenceKey: PreferenceKey { + static let defaultValue: CGRect? = nil + + static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) { + if let next = nextValue() { + value = next + } + } +} + +enum TabBarStyling { + static func separatorSegments( + totalWidth: CGFloat, + gap: ClosedRange? + ) -> (left: CGFloat, right: CGFloat) { + let clampedTotal = max(0, totalWidth) + guard let gap else { + return (left: clampedTotal, right: 0) + } + + let start = min(max(gap.lowerBound, 0), clampedTotal) + let end = min(max(gap.upperBound, 0), clampedTotal) + let normalizedStart = min(start, end) + let normalizedEnd = max(start, end) + let left = max(0, normalizedStart) + let right = max(0, clampedTotal - normalizedEnd) + return (left: left, right: right) + } +} + +struct TabContextMenuState { + let isPinned: Bool + let isUnread: Bool + let isBrowser: Bool + let isTerminal: Bool + let hasCustomTitle: Bool + let canCloseToLeft: Bool + let canCloseToRight: Bool + let canCloseOthers: Bool + let canMoveToLeftPane: Bool + let canMoveToRightPane: Bool + let isZoomed: Bool + let hasSplits: Bool + let shortcuts: [TabContextAction: KeyboardShortcut] + + var canMarkAsUnread: Bool { + !isUnread + } + + var canMarkAsRead: Bool { + isUnread + } +} + +/// Tab bar view with scrollable tabs, drag/drop support, and split buttons +struct TabBarView: View { + @Environment(BonsplitController.self) private var controller + @Environment(SplitViewController.self) private var splitViewController + + @Bindable var pane: PaneState + let isFocused: Bool + var showSplitButtons: Bool = true + + @AppStorage("workspacePresentationMode") private var presentationMode = "standard" + @AppStorage("debugFadeColorStyle") private var fadeColorStyle = 0 + @State private var isHoveringTabBar = false + @State private var dropTargetIndex: Int? + @State private var dropLifecycle: TabDropLifecycle = .idle + @State private var scrollOffset: CGFloat = 0 + @State private var contentWidth: CGFloat = 0 + @State private var containerWidth: CGFloat = 0 + @State private var selectedTabFrameInBar: CGRect? + @StateObject private var controlKeyMonitor = TabControlShortcutKeyMonitor() + + private var canScrollLeft: Bool { + scrollOffset > 1 + } + + private var canScrollRight: Bool { + // contentWidth includes the 30pt drop zone after tabs. + let tabsWidth = contentWidth - 30 + guard tabsWidth > containerWidth + 4 else { return false } + return scrollOffset < tabsWidth - containerWidth + } + + /// Whether this tab bar should show full saturation (focused or drag source) + private var shouldShowFullSaturation: Bool { + isFocused || splitViewController.dragSourcePaneId == pane.id + } + + private var tabBarSaturation: Double { + shouldShowFullSaturation ? 1.0 : 0.0 + } + + private var appearance: BonsplitConfiguration.Appearance { + controller.configuration.appearance + } + + private var showsControlShortcutHints: Bool { + isFocused && controlKeyMonitor.isShortcutHintVisible + } + + + var body: some View { + HStack(spacing: 0) { + if appearance.tabBarLeadingInset > 0 && controller.internalController.rootNode.allPaneIds.first == pane.id { + TabBarDragZoneView { return false } + .frame(width: appearance.tabBarLeadingInset) + } + // Scrollable tabs with fade overlays + GeometryReader { containerGeo in + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: TabBarMetrics.tabSpacing) { + ForEach(Array(pane.tabs.enumerated()), id: \.element.id) { index, tab in + tabItem(for: tab, at: index) + .id(tab.id) + } + + // Unified drop zone after the last tab. + dropZoneAfterTabs + } + .padding(.horizontal, TabBarMetrics.barPadding) + .padding(.trailing, showSplitButtons ? 114 : 0) + .animation(nil, value: pane.tabs.map(\.id)) + .background( + GeometryReader { contentGeo in + Color.clear + .onChange(of: contentGeo.frame(in: .named("tabScroll"))) { _, newFrame in + scrollOffset = -newFrame.minX + contentWidth = newFrame.width + } + .onAppear { + let frame = contentGeo.frame(in: .named("tabScroll")) + scrollOffset = -frame.minX + contentWidth = frame.width + } + } + ) + } + // When the tab strip is shorter than the visible area, allow dropping in the + // empty trailing space without forcing tabs to stretch. + .overlay(alignment: .trailing) { + let trailing = max(0, containerGeo.size.width - contentWidth) + if trailing >= 1 { + TabBarDragZoneView { + guard splitViewController.isInteractive else { return false } + controller.requestNewTab(kind: "terminal", inPane: pane.id) + return true + } + .frame(width: trailing, height: TabBarMetrics.tabHeight) + .onDrop(of: [.tabTransfer], delegate: TabDropDelegate( + targetIndex: pane.tabs.count, + pane: pane, + bonsplitController: controller, + controller: splitViewController, + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + )) + } + } + .coordinateSpace(name: "tabScroll") + .onAppear { + containerWidth = containerGeo.size.width + if let tabId = pane.selectedTabId { + proxy.scrollTo(tabId, anchor: .center) + } + } + .onChange(of: containerGeo.size.width) { _, newWidth in + containerWidth = newWidth + } + .onChange(of: pane.selectedTabId) { _, newTabId in + if let tabId = newTabId { + withTransaction(Transaction(animation: nil)) { + proxy.scrollTo(tabId, anchor: .center) + } + } + } + } + .frame(height: TabBarMetrics.barHeight) + .mask(combinedMask) + // Buttons float on top. No backdrop color needed because + // the mask hides scroll content and the tab bar's own + // background shows through naturally. + .overlay(alignment: .trailing) { + if showSplitButtons { + let shouldShow = presentationMode != "minimal" || isHoveringTabBar + splitButtons + .saturation(tabBarSaturation) + .padding(.bottom, 1) + .opacity(shouldShow ? 1 : 0) + .allowsHitTesting(shouldShow) + .animation(.easeInOut(duration: 0.14), value: shouldShow) + } + } + } + } + .frame(height: TabBarMetrics.barHeight) + .coordinateSpace(name: "tabBar") + .background(tabBarBackground) + .background(TabBarDragAndHoverView( + isMinimalMode: presentationMode == "minimal", + onHoverChanged: { isHoveringTabBar = $0 } + )) + .background( + TabBarHostWindowReader { window in + controlKeyMonitor.setHostWindow(window) + } + .frame(width: 0, height: 0) + ) + // Clear drop state when drag ends elsewhere (cancelled, dropped in another pane, etc.) + .onChange(of: splitViewController.draggingTab) { _, newValue in +#if DEBUG + dlog( + "tab.dragState pane=\(pane.id.id.uuidString.prefix(5)) " + + "draggingTab=\(newValue != nil ? 1 : 0) " + + "activeDragTab=\(splitViewController.activeDragTab != nil ? 1 : 0)" + ) +#endif + if newValue == nil { + dropTargetIndex = nil + dropLifecycle = .idle + } + } + .onAppear { + controlKeyMonitor.start() + } + .onPreferenceChange(SelectedTabFramePreferenceKey.self) { frame in + selectedTabFrameInBar = frame + } + .onDisappear { + controlKeyMonitor.stop() + } + } + + // MARK: - Tab Item + + @ViewBuilder + private func tabItem(for tab: TabItem, at index: Int) -> some View { + let contextMenuState = contextMenuState(for: tab, at: index) + let showsZoomIndicator = splitViewController.zoomedPaneId == pane.id && pane.selectedTabId == tab.id + TabItemView( + tab: tab, + isSelected: pane.selectedTabId == tab.id, + showsZoomIndicator: showsZoomIndicator, + appearance: appearance, + saturation: tabBarSaturation, + controlShortcutDigit: tabControlShortcutDigit(for: index, tabCount: pane.tabs.count), + showsControlShortcutHint: showsControlShortcutHints, + shortcutModifierSymbol: controlKeyMonitor.shortcutModifierSymbol, + contextMenuState: contextMenuState, + onSelect: { + // Tab selection must be instant. Animating this transaction causes the pane + // content (often swapped via opacity) to crossfade, which is undesirable for + // terminal/browser surfaces. +#if DEBUG + dlog("tab.select pane=\(pane.id.id.uuidString.prefix(5)) tab=\(tab.id.uuidString.prefix(5)) title=\"\(tab.title)\"") +#endif + withTransaction(Transaction(animation: nil)) { + pane.selectTab(tab.id) + controller.focusPane(pane.id) + } + }, + onClose: { + guard !tab.isPinned else { return } + // Close should be instant (no fade-out/removal animation). +#if DEBUG + dlog("tab.close pane=\(pane.id.id.uuidString.prefix(5)) tab=\(tab.id.uuidString.prefix(5)) title=\"\(tab.title)\"") +#endif + withTransaction(Transaction(animation: nil)) { + controller.onTabCloseRequest?(TabID(id: tab.id), pane.id) + _ = controller.closeTab(TabID(id: tab.id), inPane: pane.id) + } + }, + onZoomToggle: { + _ = splitViewController.togglePaneZoom(pane.id) + }, + onContextAction: { action in + controller.requestTabContextAction(action, for: TabID(id: tab.id), inPane: pane.id) + } + ) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: SelectedTabFramePreferenceKey.self, + value: pane.selectedTabId == tab.id + ? geometry.frame(in: .named("tabBar")) + : nil + ) + } + ) + .onDrag { + createItemProvider(for: tab) + } preview: { + TabDragPreview(tab: tab, appearance: appearance) + } + .onDrop(of: [.tabTransfer], delegate: TabDropDelegate( + targetIndex: index, + pane: pane, + bonsplitController: controller, + controller: splitViewController, + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + )) + .overlay(alignment: .leading) { + if dropTargetIndex == index { + dropIndicator + .saturation(tabBarSaturation) + } + } + } + + private func contextMenuState(for tab: TabItem, at index: Int) -> TabContextMenuState { + let leftTabs = pane.tabs.prefix(index) + let canCloseToLeft = leftTabs.contains(where: { !$0.isPinned }) + let canCloseToRight: Bool + if (index + 1) < pane.tabs.count { + canCloseToRight = pane.tabs.suffix(from: index + 1).contains(where: { !$0.isPinned }) + } else { + canCloseToRight = false + } + let canCloseOthers = pane.tabs.enumerated().contains { itemIndex, item in + itemIndex != index && !item.isPinned + } + return TabContextMenuState( + isPinned: tab.isPinned, + isUnread: tab.showsNotificationBadge, + isBrowser: tab.kind == "browser", + isTerminal: tab.kind == "terminal", + hasCustomTitle: tab.hasCustomTitle, + canCloseToLeft: canCloseToLeft, + canCloseToRight: canCloseToRight, + canCloseOthers: canCloseOthers, + canMoveToLeftPane: controller.adjacentPane(to: pane.id, direction: .left) != nil, + canMoveToRightPane: controller.adjacentPane(to: pane.id, direction: .right) != nil, + isZoomed: splitViewController.zoomedPaneId == pane.id, + hasSplits: splitViewController.rootNode.allPaneIds.count > 1, + shortcuts: controller.contextMenuShortcuts + ) + } + + // MARK: - Item Provider + + private func createItemProvider(for tab: TabItem) -> NSItemProvider { + #if DEBUG + NSLog("[Bonsplit Drag] createItemProvider for tab: \(tab.title)") + #endif +#if DEBUG + dlog("tab.dragStart pane=\(pane.id.id.uuidString.prefix(5)) tab=\(tab.id.uuidString.prefix(5)) title=\"\(tab.title)\"") +#endif + // Clear any stale drop indicator from previous incomplete drag + dropTargetIndex = nil + dropLifecycle = .idle + + // Set drag source for visual feedback (observable) and drop delegates (non-observable). + splitViewController.dragGeneration += 1 + splitViewController.draggingTab = tab + splitViewController.dragSourcePaneId = pane.id + splitViewController.activeDragTab = tab + splitViewController.activeDragSourcePaneId = pane.id + + // Install a one-shot mouse-up monitor to clear stale drag state if the drag is + // cancelled (dropped outside any valid target). SwiftUI's onDrag doesn't provide + // a drag-cancelled callback, so performDrop never fires and draggingTab stays set, + // which disables hit testing on all content views. + let controller = splitViewController + let dragGen = controller.dragGeneration + var monitorRef: Any? + monitorRef = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in + // One-shot: remove ourselves, then clean up stale drag state. + if let m = monitorRef { + NSEvent.removeMonitor(m) + monitorRef = nil + } + // Use async to avoid mutating @Observable state during event dispatch. + DispatchQueue.main.async { + guard controller.dragGeneration == dragGen else { return } + if controller.draggingTab != nil || controller.activeDragTab != nil { +#if DEBUG + dlog("tab.dragCancel (stale draggingTab cleared)") +#endif + controller.draggingTab = nil + controller.dragSourcePaneId = nil + controller.activeDragTab = nil + controller.activeDragSourcePaneId = nil + } + } + return event + } + + let transfer = TabTransferData(tab: tab, sourcePaneId: pane.id.id) + if let data = try? JSONEncoder().encode(transfer) { + let provider = NSItemProvider() + provider.registerDataRepresentation( + forTypeIdentifier: UTType.tabTransfer.identifier, + visibility: .ownProcess + ) { completion in + completion(data, nil) + return nil + } +#if DEBUG + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + let types = NSPasteboard(name: .drag).types?.map(\.rawValue).joined(separator: ",") ?? "-" + dlog("tab.dragPasteboard types=\(types)") + } +#endif + return provider + } + return NSItemProvider() + } + + private func tabControlShortcutDigit(for index: Int, tabCount: Int) -> Int? { + for digit in 1...9 { + if tabIndexForControlShortcutDigit(digit, tabCount: tabCount) == index { + return digit + } + } + return nil + } + + private func tabIndexForControlShortcutDigit(_ digit: Int, tabCount: Int) -> Int? { + guard tabCount > 0, digit >= 1, digit <= 9 else { return nil } + if digit == 9 { + return tabCount - 1 + } + let index = digit - 1 + return index < tabCount ? index : nil + } + + // MARK: - Drop Zone at End + + @ViewBuilder + private var dropZoneAfterTabs: some View { + TabBarDragZoneView { + guard splitViewController.isInteractive else { return false } + controller.requestNewTab(kind: "terminal", inPane: pane.id) + return true + } + .frame(width: 30, height: TabBarMetrics.tabHeight) + .onDrop(of: [.tabTransfer], delegate: TabDropDelegate( + targetIndex: pane.tabs.count, + pane: pane, + bonsplitController: controller, + controller: splitViewController, + dropTargetIndex: $dropTargetIndex, + dropLifecycle: $dropLifecycle + )) + .overlay(alignment: .leading) { + if dropTargetIndex == pane.tabs.count { + dropIndicator + .saturation(tabBarSaturation) + } + } + } + + // MARK: - Drop Indicator + + @ViewBuilder + private var dropIndicator: some View { + Capsule() + .fill(TabBarColors.dropIndicator(for: appearance)) + .frame(width: TabBarMetrics.dropIndicatorWidth, height: TabBarMetrics.dropIndicatorHeight) + .offset(x: -1) + } + + // MARK: - Split Buttons + + @ViewBuilder + private var splitButtons: some View { + let tooltips = controller.configuration.appearance.splitButtonTooltips + HStack(spacing: 4) { + Button { + controller.requestNewTab(kind: "terminal", inPane: pane.id) + } label: { + Image(systemName: "terminal") + .font(.system(size: 12)) + } + .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .safeHelp(tooltips.newTerminal) + + Button { + controller.requestNewTab(kind: "browser", inPane: pane.id) + } label: { + Image(systemName: "globe") + .font(.system(size: 12)) + } + .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .safeHelp(tooltips.newBrowser) + + Button { + // 120fps animation handled by SplitAnimator + controller.splitPane(pane.id, orientation: .horizontal) + } label: { + Image(systemName: "square.split.2x1") + .font(.system(size: 12)) + } + .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .safeHelp(tooltips.splitRight) + + Button { + // 120fps animation handled by SplitAnimator + controller.splitPane(pane.id, orientation: .vertical) + } label: { + Image(systemName: "square.split.1x2") + .font(.system(size: 12)) + } + .buttonStyle(SplitActionButtonStyle(appearance: appearance)) + .safeHelp(tooltips.splitDown) + } + .padding(.leading, 6) + .padding(.trailing, 8) + } + + + private static func buttonBackdropColor( + for appearance: BonsplitConfiguration.Appearance, + focused: Bool, + style: Int + ) -> NSColor { + switch style { + case 1: // raw paneBackground forced opaque + return TabBarColors.nsColorPaneBackground(for: appearance).withAlphaComponent(1.0) + case 2: // barBackground (tab bar chrome) + let c = NSColor(TabBarColors.barBackground(for: appearance)) + return (c.usingColorSpace(.sRGB) ?? c).withAlphaComponent(1.0) + case 3: // windowBackgroundColor + return NSColor.windowBackgroundColor.withAlphaComponent(1.0) + case 4: // controlBackgroundColor + return NSColor.controlBackgroundColor.withAlphaComponent(1.0) + case 5: // pre-composited barBackground over windowBg + let chrome = NSColor(TabBarColors.barBackground(for: appearance)) + let winBg = NSColor.windowBackgroundColor + guard let fg = chrome.usingColorSpace(.sRGB), + let bk = winBg.usingColorSpace(.sRGB) else { + return chrome.withAlphaComponent(1.0) + } + let a: CGFloat = focused ? fg.alphaComponent : fg.alphaComponent * 0.95 + let oneMinusA = 1.0 - a + let r = fg.redComponent * a + bk.redComponent * oneMinusA + let g = fg.greenComponent * a + bk.greenComponent * oneMinusA + let b = fg.blueComponent * a + bk.blueComponent * oneMinusA + return NSColor(red: r, green: g, blue: b, alpha: 1.0) + default: // 0: pre-composited paneBackground over windowBg + return precompositedPaneBackground(for: appearance, focused: focused) + } + } + + /// Pre-composite the pane background over the window background to produce + /// a flat opaque color that matches what .background(barFill) looks like + /// after compositing. Avoids double-compositing mismatch on overlays. + private static func precompositedPaneBackground( + for appearance: BonsplitConfiguration.Appearance, + focused: Bool + ) -> NSColor { + let chrome = TabBarColors.nsColorPaneBackground(for: appearance) + let winBg = NSColor.windowBackgroundColor + guard let fg = chrome.usingColorSpace(.sRGB), + let bk = winBg.usingColorSpace(.sRGB) else { + return chrome.withAlphaComponent(1.0) + } + let a: CGFloat = focused ? fg.alphaComponent : fg.alphaComponent * 0.95 + let oneMinusA = 1.0 - a + let r = fg.redComponent * a + bk.redComponent * oneMinusA + let g = fg.greenComponent * a + bk.greenComponent * oneMinusA + let b = fg.blueComponent * a + bk.blueComponent * oneMinusA + return NSColor(red: r, green: g, blue: b, alpha: 1.0) + } + + // MARK: - Combined Mask (scroll fades + button area) + + @ViewBuilder + private var combinedMask: some View { + let fadeWidth: CGFloat = 24 + let shouldShowButtons = showSplitButtons && (presentationMode != "minimal" || isHoveringTabBar) + let buttonClearWidth: CGFloat = shouldShowButtons ? 90 : 0 + let buttonFadeWidth: CGFloat = shouldShowButtons ? fadeWidth : 0 + + HStack(spacing: 0) { + // Left scroll fade + LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) + .frame(width: canScrollLeft ? fadeWidth : 0) + + // Visible content area + Rectangle().fill(Color.black) + + // Right: either scroll fade or button area fade + LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) + .frame(width: canScrollRight || shouldShowButtons ? fadeWidth : 0) + + // Button clear area (content hidden here) + if shouldShowButtons { + Color.clear.frame(width: buttonClearWidth) + } + } + .animation(.easeInOut(duration: 0.14), value: shouldShowButtons) + } + + // MARK: - Fade Overlays + + /// Mask that fades scroll content at the edges instead of overlaying + /// a colored gradient. The mask uses black (visible) → clear (hidden), + /// so the tab bar background shows through naturally with no compositing. + @ViewBuilder + private var fadeOverlays: some View { + let fadeWidth: CGFloat = 24 + HStack(spacing: 0) { + LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) + .frame(width: canScrollLeft ? fadeWidth : 0) + + Rectangle().fill(Color.black) + + LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) + .frame(width: canScrollRight ? fadeWidth : 0) + } + } + + // MARK: - Background + + @ViewBuilder + private var tabBarBackground: some View { + let barFill = isFocused + ? TabBarColors.barBackground(for: appearance) + : TabBarColors.barBackground(for: appearance).opacity(0.95) + + Rectangle() + .fill(barFill) + .overlay(alignment: .bottom) { + GeometryReader { geometry in + let separator = TabBarColors.separator(for: appearance) + let gapRange: ClosedRange? = selectedTabFrameInBar.map { frame in + frame.minX...frame.maxX + } + let segments = TabBarStyling.separatorSegments( + totalWidth: geometry.size.width, + gap: gapRange + ) + + HStack(spacing: 0) { + Rectangle() + .fill(separator) + .frame(width: segments.left, height: 1) + Spacer(minLength: 0) + Rectangle() + .fill(separator) + .frame(width: segments.right, height: 1) + } + } + .frame(height: 1) + } + } +} + +private struct SplitActionButtonStyle: ButtonStyle { + let appearance: BonsplitConfiguration.Appearance + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(TabBarColors.splitActionIcon(for: appearance, isPressed: configuration.isPressed)) + } +} + +/// Background view that provides window-drag-from-empty-space in minimal mode +/// and hover tracking via NSTrackingArea (replacing .contentShape + .onHover). +/// As a .background(), AppKit routes clicks to tabs/buttons in front first; +/// this view only receives hits in truly empty space. +private struct TabBarDragAndHoverView: NSViewRepresentable { + let isMinimalMode: Bool + let onHoverChanged: (Bool) -> Void + + func makeNSView(context: Context) -> TabBarBackgroundNSView { + let view = TabBarBackgroundNSView() + view.isMinimalMode = isMinimalMode + view.onHoverChanged = onHoverChanged + return view + } + + func updateNSView(_ nsView: TabBarBackgroundNSView, context: Context) { + nsView.isMinimalMode = isMinimalMode + nsView.onHoverChanged = onHoverChanged + } + + final class TabBarBackgroundNSView: NSView { + var isMinimalMode = false + var onHoverChanged: ((Bool) -> Void)? + private var hoverTrackingArea: NSTrackingArea? + + override var mouseDownCanMoveWindow: Bool { false } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let existing = hoverTrackingArea { + removeTrackingArea(existing) + } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp], + owner: self + ) + addTrackingArea(area) + hoverTrackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + onHoverChanged?(true) + } + + override func mouseExited(with event: NSEvent) { + onHoverChanged?(false) + } + + override func mouseDown(with event: NSEvent) { + guard isMinimalMode, let window else { + super.mouseDown(with: event) + return + } + if event.clickCount >= 2 { + let action = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?["AppleActionOnDoubleClick"] as? String + switch action { + case "Minimize": window.miniaturize(nil) + default: window.zoom(nil) + } + return + } + let wasMovable = window.isMovable + window.isMovable = true + window.performDrag(with: event) + window.isMovable = wasMovable + } + } +} + +private struct TabBarDragZoneView: NSViewRepresentable { + let onDoubleClick: () -> Bool + + func makeNSView(context: Context) -> DragNSView { + let view = DragNSView() + view.onDoubleClick = onDoubleClick + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.clear.cgColor + return view + } + + func updateNSView(_ nsView: DragNSView, context: Context) { + nsView.onDoubleClick = onDoubleClick + } + + final class DragNSView: NSView { + var onDoubleClick: (() -> Bool)? + + override var mouseDownCanMoveWindow: Bool { + return UserDefaults.standard.string(forKey: "workspacePresentationMode") == "minimal" + } + + override func hitTest(_ point: NSPoint) -> NSView? { + return bounds.contains(point) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + guard let window = self.window else { + super.mouseDown(with: event) + return + } + + if event.clickCount >= 2 { + if UserDefaults.standard.string(forKey: "workspacePresentationMode") == "minimal" { + let action = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?["AppleActionOnDoubleClick"] as? String + switch action { + case "Minimize": window.miniaturize(nil) + default: window.zoom(nil) + } + return + } else { + if onDoubleClick?() == true { + return + } + } + } + + if UserDefaults.standard.string(forKey: "workspacePresentationMode") == "minimal" { + let wasMovable = window.isMovable + window.isMovable = true + window.performDrag(with: event) + window.isMovable = wasMovable + } else { + super.mouseDown(with: event) + } + } + } +} + +private struct TabControlShortcutStoredShortcut: Decodable { + let key: String + let command: Bool + let shift: Bool + let option: Bool + let control: Bool + + init( + key: String, + command: Bool, + shift: Bool, + option: Bool, + control: Bool + ) { + self.key = key + self.command = command + self.shift = shift + self.option = option + self.control = control + } + + var modifierFlags: NSEvent.ModifierFlags { + var flags: NSEvent.ModifierFlags = [] + if command { flags.insert(.command) } + if shift { flags.insert(.shift) } + if option { flags.insert(.option) } + if control { flags.insert(.control) } + return flags + } + + var modifierSymbol: String { + var parts: [String] = [] + if control { parts.append("⌃") } + if option { parts.append("⌥") } + if shift { parts.append("⇧") } + if command { parts.append("⌘") } + return parts.joined() + } +} + +private enum TabControlShortcutSettings { + static let surfaceByNumberKey = "shortcut.selectSurfaceByNumber" + static let defaultShortcut = TabControlShortcutStoredShortcut( + key: "1", + command: false, + shift: false, + option: false, + control: true + ) + + static func surfaceByNumberShortcut(defaults: UserDefaults = .standard) -> TabControlShortcutStoredShortcut { + guard let data = defaults.data(forKey: surfaceByNumberKey), + let shortcut = try? JSONDecoder().decode(TabControlShortcutStoredShortcut.self, from: data) else { + return defaultShortcut + } + return shortcut + } +} + +struct TabControlShortcutModifier: Equatable { + let modifierFlags: NSEvent.ModifierFlags + let symbol: String +} + +enum TabControlShortcutHintPolicy { + static let intentionalHoldDelay: TimeInterval = 0.30 + static let showHintsOnCommandHoldKey = "shortcutHintShowOnCommandHold" + static let defaultShowHintsOnCommandHold = true + + static func showHintsOnCommandHoldEnabled(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: showHintsOnCommandHoldKey) != nil else { + return defaultShowHintsOnCommandHold + } + return defaults.bool(forKey: showHintsOnCommandHoldKey) + } + + static func hintModifier( + for modifierFlags: NSEvent.ModifierFlags, + defaults: UserDefaults = .standard + ) -> TabControlShortcutModifier? { + guard showHintsOnCommandHoldEnabled(defaults: defaults) else { return nil } + let flags = modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + let shortcut = TabControlShortcutSettings.surfaceByNumberShortcut(defaults: defaults) + guard flags == shortcut.modifierFlags else { return nil } + return TabControlShortcutModifier( + modifierFlags: shortcut.modifierFlags, + symbol: shortcut.modifierSymbol + ) + } + + static func isCurrentWindow( + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int? + ) -> Bool { + guard let hostWindowNumber, hostWindowIsKey else { return false } + if let eventWindowNumber { + return eventWindowNumber == hostWindowNumber + } + return keyWindowNumber == hostWindowNumber + } + + static func shouldShowHints( + for modifierFlags: NSEvent.ModifierFlags, + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int?, + defaults: UserDefaults = .standard + ) -> Bool { + hintModifier(for: modifierFlags, defaults: defaults) != nil && + isCurrentWindow( + hostWindowNumber: hostWindowNumber, + hostWindowIsKey: hostWindowIsKey, + eventWindowNumber: eventWindowNumber, + keyWindowNumber: keyWindowNumber + ) + } +} + +private struct TabBarHostWindowReader: NSViewRepresentable { + let onResolve: (NSWindow?) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { [weak view] in + onResolve(view?.window) + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { [weak nsView] in + onResolve(nsView?.window) + } + } +} + +@MainActor +private final class TabControlShortcutKeyMonitor: ObservableObject { + @Published private(set) var isShortcutHintVisible = false + @Published private(set) var shortcutModifierSymbol = "⌃" + + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? + private var flagsMonitor: Any? + private var keyDownMonitor: Any? + private var resignObserver: NSObjectProtocol? + private var pendingShowWorkItem: DispatchWorkItem? + private var pendingModifier: TabControlShortcutModifier? + + func setHostWindow(_ window: NSWindow?) { + guard hostWindow !== window else { return } + removeHostWindowObservers() + hostWindow = window + guard let window else { + cancelPendingHintShow(resetVisible: true) + return + } + + hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.update(from: NSEvent.modifierFlags, eventWindow: nil) + } + } + + hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + + func start() { + guard flagsMonitor == nil else { + update(from: NSEvent.modifierFlags, eventWindow: nil) + return + } + + flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + self?.update(from: event.modifierFlags, eventWindow: event.window) + return event + } + + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard self?.isCurrentWindow(eventWindow: event.window) == true else { return event } + self?.cancelPendingHintShow(resetVisible: true) + return event + } + + resignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + + func stop() { + if let flagsMonitor { + NSEvent.removeMonitor(flagsMonitor) + self.flagsMonitor = nil + } + if let keyDownMonitor { + NSEvent.removeMonitor(keyDownMonitor) + self.keyDownMonitor = nil + } + if let resignObserver { + NotificationCenter.default.removeObserver(resignObserver) + self.resignObserver = nil + } + removeHostWindowObservers() + cancelPendingHintShow(resetVisible: true) + } + + private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { + TabControlShortcutHintPolicy.isCurrentWindow( + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) + } + + private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { + guard TabControlShortcutHintPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { + cancelPendingHintShow(resetVisible: true) + return + } + + guard let modifier = TabControlShortcutHintPolicy.hintModifier(for: modifierFlags) else { + cancelPendingHintShow(resetVisible: true) + return + } + + if isShortcutHintVisible { + shortcutModifierSymbol = modifier.symbol + return + } + + queueHintShow(for: modifier) + } + + private func queueHintShow(for modifier: TabControlShortcutModifier) { + if pendingModifier == modifier, pendingShowWorkItem != nil { + return + } + + pendingShowWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.pendingShowWorkItem = nil + self.pendingModifier = nil + guard TabControlShortcutHintPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { return } + guard let currentModifier = TabControlShortcutHintPolicy.hintModifier(for: NSEvent.modifierFlags) else { return } + self.shortcutModifierSymbol = currentModifier.symbol + withAnimation(.easeInOut(duration: 0.14)) { + self.isShortcutHintVisible = true + } + } + + pendingModifier = modifier + pendingShowWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + TabControlShortcutHintPolicy.intentionalHoldDelay, execute: workItem) + } + + private func cancelPendingHintShow(resetVisible: Bool) { + pendingShowWorkItem?.cancel() + pendingShowWorkItem = nil + pendingModifier = nil + if resetVisible { + withAnimation(.easeInOut(duration: 0.14)) { + isShortcutHintVisible = false + } + } + } + + private func removeHostWindowObservers() { + if let hostWindowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) + self.hostWindowDidBecomeKeyObserver = nil + } + if let hostWindowDidResignKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) + self.hostWindowDidResignKeyObserver = nil + } + } +} + + +/// Drop lifecycle state to prevent dropUpdated from re-setting state after performDrop +enum TabDropLifecycle { + case idle + case hovering +} + +// MARK: - Tab Drop Delegate + +struct TabDropDelegate: DropDelegate { + let targetIndex: Int + let pane: PaneState + let bonsplitController: BonsplitController + let controller: SplitViewController + @Binding var dropTargetIndex: Int? + @Binding var dropLifecycle: TabDropLifecycle + + func performDrop(info: DropInfo) -> Bool { + #if DEBUG + NSLog("[Bonsplit Drag] performDrop called, targetIndex: \(targetIndex)") + #endif +#if DEBUG + dlog("tab.drop pane=\(pane.id.id.uuidString.prefix(5)) targetIndex=\(targetIndex)") +#endif + + // Ensure all drag/drop side-effects run on the main actor. SwiftUI can call these + // callbacks off-main, and SplitViewController is @MainActor. + if !Thread.isMainThread { + return DispatchQueue.main.sync { + performDrop(info: info) + } + } + + // Read from non-observable drag state — @Observable writes from createItemProvider + // may not have propagated yet when performDrop runs. + guard let draggedTab = controller.activeDragTab ?? controller.draggingTab, + let sourcePaneId = controller.activeDragSourcePaneId ?? controller.dragSourcePaneId else { + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess else { + return false + } + let request = BonsplitController.ExternalTabDropRequest( + tabId: TabID(id: transfer.tab.id), + sourcePaneId: PaneID(id: transfer.sourcePaneId), + destination: .insert(targetPane: pane.id, targetIndex: targetIndex) + ) + let handled = bonsplitController.onExternalTabDrop?(request) ?? false + if handled { + dropLifecycle = .idle + dropTargetIndex = nil + } + return handled + } + + // Execute synchronously when possible so the dragged tab disappears immediately. + let applyMove = { + // Ensure the move itself doesn't animate. + withTransaction(Transaction(animation: nil)) { + if sourcePaneId == pane.id { + guard let sourceIndex = pane.tabs.firstIndex(where: { $0.id == draggedTab.id }) else { return } + // Same-pane no-op: don't mutate the model (and don't show an indicator). + if targetIndex == sourceIndex || targetIndex == sourceIndex + 1 { + return + } + pane.moveTab(from: sourceIndex, to: targetIndex) + } else { + _ = bonsplitController.moveTab( + TabID(id: draggedTab.id), + toPane: pane.id, + atIndex: targetIndex + ) + } + } + } + + applyMove() + + // Clear visual state immediately to prevent lingering indicators. + // Must happen synchronously before returning, not in async callback. + // Setting dropLifecycle to idle prevents dropUpdated from re-setting dropTargetIndex. + dropLifecycle = .idle + dropTargetIndex = nil + controller.draggingTab = nil + controller.dragSourcePaneId = nil + controller.activeDragTab = nil + controller.activeDragSourcePaneId = nil + + return true + } + + func dropEntered(info: DropInfo) { + #if DEBUG + NSLog("[Bonsplit Drag] dropEntered at index: \(targetIndex)") + dlog( + "tab.dropEntered pane=\(pane.id.id.uuidString.prefix(5)) targetIndex=\(targetIndex) " + + "hasDrag=\(controller.draggingTab != nil ? 1 : 0) " + + "hasActive=\(controller.activeDragTab != nil ? 1 : 0)" + ) + #endif + dropLifecycle = .hovering + if shouldSuppressIndicatorForNoopSamePaneDrop() { + dropTargetIndex = nil + } else { + dropTargetIndex = targetIndex + } + } + + func dropExited(info: DropInfo) { + #if DEBUG + NSLog("[Bonsplit Drag] dropExited from index: \(targetIndex)") + dlog("tab.dropExited pane=\(pane.id.id.uuidString.prefix(5)) targetIndex=\(targetIndex)") + #endif + dropLifecycle = .idle + if dropTargetIndex == targetIndex { + dropTargetIndex = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + // Guard against dropUpdated firing after performDrop/dropExited + // This is the key fix for the lingering indicator bug + guard dropLifecycle == .hovering else { +#if DEBUG + dlog("tab.dropUpdated.skip pane=\(pane.id.id.uuidString.prefix(5)) targetIndex=\(targetIndex) reason=lifecycle_idle") +#endif + return DropProposal(operation: .move) + } + // Only update if this is the active target, and suppress same-pane no-op indicators. + if shouldSuppressIndicatorForNoopSamePaneDrop() { + if dropTargetIndex == targetIndex { + dropTargetIndex = nil + } + } else if dropTargetIndex != targetIndex { + dropTargetIndex = targetIndex + } +#if DEBUG + dlog( + "tab.dropUpdated pane=\(pane.id.id.uuidString.prefix(5)) targetIndex=\(targetIndex) " + + "dropTarget=\(dropTargetIndex.map(String.init) ?? "nil")" + ) +#endif + return DropProposal(operation: .move) + } + + func validateDrop(info: DropInfo) -> Bool { + // Reject drops on inactive workspaces whose views are kept alive in a ZStack. + guard controller.isInteractive else { +#if DEBUG + dlog("tab.validateDrop pane=\(pane.id.id.uuidString.prefix(5)) allowed=0 reason=inactive") +#endif + return false + } + // The custom UTType alone is sufficient — only Bonsplit tab drags produce it. + // Do NOT gate on draggingTab != nil: @Observable changes from createItemProvider + // may not have propagated to the drop delegate yet, causing false rejections. + let hasType = info.hasItemsConforming(to: [.tabTransfer]) + guard hasType else { return false } + + // Local drags use in-memory state and are always same-process. + if controller.activeDragTab != nil || controller.draggingTab != nil { + return true + } + + // External drags (another Bonsplit controller) must include a payload from this process. + guard let transfer = decodeTransfer(from: info), + transfer.isFromCurrentProcess else { + return false + } +#if DEBUG + let hasDrag = controller.draggingTab != nil + let hasActive = controller.activeDragTab != nil + dlog( + "tab.validateDrop pane=\(pane.id.id.uuidString.prefix(5)) " + + "allowed=\(hasType ? 1 : 0) hasDrag=\(hasDrag ? 1 : 0) hasActive=\(hasActive ? 1 : 0)" + ) +#endif + return true + } + + private func shouldSuppressIndicatorForNoopSamePaneDrop() -> Bool { + guard let draggedTab = controller.draggingTab, + controller.dragSourcePaneId == pane.id, + let sourceIndex = pane.tabs.firstIndex(where: { $0.id == draggedTab.id }) else { + return false + } + // Insertion indices are expressed in "original array" coordinates; after removal, + // inserting at `sourceIndex` or `sourceIndex + 1` results in no change. + return targetIndex == sourceIndex || targetIndex == sourceIndex + 1 + } + + private func decodeTransfer(from string: String) -> TabTransferData? { + guard let data = string.data(using: .utf8), + let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) else { + return nil + } + return transfer + } + + private func decodeTransfer(from info: DropInfo) -> TabTransferData? { + let pasteboard = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType(UTType.tabTransfer.identifier) + if let data = pasteboard.data(forType: type), + let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) { + return transfer + } + if let raw = pasteboard.string(forType: type) { + return decodeTransfer(from: raw) + } + return nil + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabDragPreview.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabDragPreview.swift new file mode 100644 index 00000000000..7987397fb37 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabDragPreview.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Preview shown during tab drag operations +struct TabDragPreview: View { + let tab: TabItem + let appearance: BonsplitConfiguration.Appearance + + var body: some View { + HStack(spacing: TabBarMetrics.contentSpacing) { + if let iconName = tab.icon { + Image(systemName: iconName) + .font(.system(size: TabBarMetrics.iconSize)) + .foregroundStyle(TabBarColors.activeText(for: appearance)) + } + + Text(tab.title) + .font(.system(size: TabBarMetrics.titleFontSize)) + .lineLimit(1) + .foregroundStyle(TabBarColors.activeText(for: appearance)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: TabBarMetrics.tabCornerRadius, style: .continuous) + .fill(TabBarColors.activeTabBackground(for: appearance)) + .shadow(color: .black.opacity(0.2), radius: 4, y: 2) + ) + .opacity(0.9) + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabItemView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabItemView.swift new file mode 100644 index 00000000000..25472115381 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -0,0 +1,638 @@ +import SwiftUI +import AppKit + +private enum TabControlShortcutHintDebugSettings { + static let xKey = "shortcutHintPaneTabXOffset" + static let yKey = "shortcutHintPaneTabYOffset" + static let alwaysShowKey = "shortcutHintAlwaysShow" + static let defaultX = 0.0 + static let defaultY = 0.0 + static let defaultAlwaysShow = false + static let range: ClosedRange = -20...20 + + static func clamped(_ value: Double) -> Double { + min(max(value, range.lowerBound), range.upperBound) + } +} + +enum TabItemStyling { + static func iconSaturation(hasRasterIcon: Bool, tabSaturation: Double) -> Double { + hasRasterIcon ? 1.0 : tabSaturation + } + + static func shouldShowHoverBackground(isHovered: Bool, isSelected: Bool) -> Bool { + isHovered && !isSelected + } + + static func resolvedFaviconImage(existing: NSImage?, incomingData: Data?) -> NSImage? { + guard let incomingData else { return nil } + if let decoded = NSImage(data: incomingData) { + // Favicon bitmaps must never be treated as template/tintable symbols. + decoded.isTemplate = false + return decoded + } + return existing + } +} + +/// Individual tab view with icon, title, close button, and dirty indicator +struct TabItemView: View { + let tab: TabItem + let isSelected: Bool + let showsZoomIndicator: Bool + let appearance: BonsplitConfiguration.Appearance + let saturation: Double + let controlShortcutDigit: Int? + let showsControlShortcutHint: Bool + let shortcutModifierSymbol: String + let contextMenuState: TabContextMenuState + let onSelect: () -> Void + let onClose: () -> Void + let onZoomToggle: () -> Void + let onContextAction: (TabContextAction) -> Void + + @State private var isHovered = false + @State private var isCloseHovered = false + @State private var isZoomHovered = false + @State private var showGlobeFallback = true + @State private var globeFallbackWorkItem: DispatchWorkItem? + @State private var lastIsLoadingObserved = false + @State private var lastLoadingStoppedAt: Date? + @State private var renderedFaviconData: Data? + @State private var renderedFaviconImage: NSImage? + @AppStorage(TabControlShortcutHintDebugSettings.xKey) private var controlShortcutHintXOffset = TabControlShortcutHintDebugSettings.defaultX + @AppStorage(TabControlShortcutHintDebugSettings.yKey) private var controlShortcutHintYOffset = TabControlShortcutHintDebugSettings.defaultY + @AppStorage(TabControlShortcutHintDebugSettings.alwaysShowKey) private var alwaysShowShortcutHints = TabControlShortcutHintDebugSettings.defaultAlwaysShow + + var body: some View { + HStack(spacing: 0) { + // Icon + title block uses the standard spacing, but keep the close affordance tight. + HStack(spacing: TabBarMetrics.contentSpacing) { + let iconSlotSize = TabBarMetrics.iconSize + let iconTint = isSelected + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance) + let faviconImage = renderedFaviconImage ?? tab.iconImageData.flatMap { NSImage(data: $0) } + + Group { + if tab.isLoading { + // Slightly smaller than the icon slot so it reads cleaner at tab scale. + TabLoadingSpinner(size: iconSlotSize * 0.86, color: iconTint) + } else if let image = faviconImage { + FaviconIconView(image: image) + .frame(width: iconSlotSize, height: iconSlotSize, alignment: .center) + .clipped() + } else if let iconName = tab.icon { + if iconName == "globe", !showGlobeFallback { + // Avoid a distracting "globe -> favicon" flash: show a neutral placeholder + // briefly while the favicon fetch finishes. If no favicon arrives, we + // reveal the globe after a short delay. + RoundedRectangle(cornerRadius: 3) + .stroke(iconTint.opacity(0.25), lineWidth: 1) + } else { + Image(systemName: iconName) + .font(.system(size: glyphSize(for: iconName))) + .foregroundStyle(iconTint) + } + } + } + // Keep downloaded favicon bitmaps in full color even for inactive tab bars. + .saturation(TabItemStyling.iconSaturation(hasRasterIcon: faviconImage != nil, tabSaturation: saturation)) + .transaction { tx in + // Prevent incidental parent animations from briefly fading icon content. + tx.animation = nil + } + .frame(width: iconSlotSize, height: iconSlotSize, alignment: .center) + .onAppear { + updateRenderedFaviconImage() + updateGlobeFallback() + } + .onDisappear { + globeFallbackWorkItem?.cancel() + globeFallbackWorkItem = nil + } + .onChange(of: tab.isLoading) { _ in updateGlobeFallback() } + .onChange(of: tab.iconImageData) { _ in + updateRenderedFaviconImage() + updateGlobeFallback() + } + .onChange(of: tab.icon) { _ in updateGlobeFallback() } + + Text(tab.title) + .font(.system(size: TabBarMetrics.titleFontSize)) + .lineLimit(1) + .foregroundStyle( + isSelected + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance) + ) + .saturation(saturation) + + if showsZoomIndicator { + Button { + onZoomToggle() + } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: max(8, TabBarMetrics.titleFontSize - 2), weight: .semibold)) + .foregroundStyle( + isZoomHovered + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance) + ) + .frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize) + .background( + Circle() + .fill( + isZoomHovered + ? TabBarColors.hoveredTabBackground(for: appearance) + : .clear + ) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + isZoomHovered = hovering + } + .saturation(saturation) + .accessibilityLabel("Exit zoom") + } + } + + Spacer(minLength: 0) + + // Close button / dirty indicator / shortcut hint share the same trailing slot. + trailingAccessory + } + .padding(.horizontal, TabBarMetrics.tabHorizontalPadding) + .offset(y: isSelected ? 0.5 : 0) + .frame( + minWidth: TabBarMetrics.tabMinWidth, + maxWidth: TabBarMetrics.tabMaxWidth, + minHeight: TabBarMetrics.tabHeight, + maxHeight: TabBarMetrics.tabHeight + ) + .padding(.bottom, isSelected ? 1 : 0) + .background(tabBackground.saturation(saturation)) + .animation(.easeInOut(duration: 0.14), value: showsShortcutHint) + .contentShape(Rectangle()) + // Middle click to close (macOS convention). + // Uses an AppKit event monitor so it doesn't interfere with left click selection or drag/reorder. + .background(MiddleClickMonitorView(onMiddleClick: { + guard !tab.isPinned else { return } + onClose() + })) + .onTapGesture { + onSelect() + } + .onHover { hovering in + // Keep icon rendering stable while hovering; only accessory/background elements animate. + isHovered = hovering + } + .contextMenu { + contextMenuContent + } + .accessibilityElement(children: .combine) + .accessibilityLabel(tab.title) + .accessibilityValue(accessibilityValue) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + + private func glyphSize(for iconName: String) -> CGFloat { + // `terminal.fill` reads visually heavier than most symbols at the same point size. + // Hardcode sizes to avoid cross-glyph layout shifts. + if iconName == "terminal.fill" || iconName == "terminal" || iconName == "globe" { + return max(10, TabBarMetrics.iconSize - 2.5) + } + return TabBarMetrics.iconSize + } + + private var shortcutHintLabel: String? { + guard let controlShortcutDigit else { return nil } + return "\(shortcutModifierSymbol)\(controlShortcutDigit)" + } + + private var showsShortcutHint: Bool { + (showsControlShortcutHint || alwaysShowShortcutHints) && shortcutHintLabel != nil + } + + private var shortcutHintSlotWidth: CGFloat { + guard let label = shortcutHintLabel else { + return TabBarMetrics.closeButtonSize + } + let positiveDebugInset = max(0, CGFloat(TabControlShortcutHintDebugSettings.clamped(controlShortcutHintXOffset))) + 2 + return max(TabBarMetrics.closeButtonSize, shortcutHintWidth(for: label) + positiveDebugInset) + } + + private func shortcutHintWidth(for label: String) -> CGFloat { + let font = NSFont.systemFont(ofSize: max(8, TabBarMetrics.titleFontSize - 2), weight: .semibold) + let textWidth = (label as NSString).size(withAttributes: [.font: font]).width + return ceil(textWidth) + 8 + } + + @ViewBuilder + private var trailingAccessory: some View { + ZStack(alignment: .center) { + if let shortcutHintLabel { + Text(shortcutHintLabel) + .font(.system(size: max(8, TabBarMetrics.titleFontSize - 2), weight: .semibold, design: .rounded)) + .monospacedDigit() + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .foregroundStyle( + isSelected + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance) + ) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + Capsule(style: .continuous) + .fill(.regularMaterial) + .overlay( + Capsule(style: .continuous) + .stroke(Color.white.opacity(0.30), lineWidth: 0.8) + ) + .shadow(color: Color.black.opacity(0.22), radius: 2, x: 0, y: 1) + ) + .offset( + x: TabControlShortcutHintDebugSettings.clamped(controlShortcutHintXOffset), + y: TabControlShortcutHintDebugSettings.clamped(controlShortcutHintYOffset) + ) + .opacity(showsShortcutHint ? 1 : 0) + .allowsHitTesting(false) + } + + closeOrDirtyIndicator + .opacity(showsShortcutHint ? 0 : 1) + .allowsHitTesting(!showsShortcutHint) + } + .frame(width: shortcutHintSlotWidth, height: TabBarMetrics.closeButtonSize, alignment: .center) + .animation(.easeInOut(duration: 0.14), value: showsShortcutHint) + } + + private func updateGlobeFallback() { + // Track load transitions so we can avoid an "empty placeholder -> globe" flash on brand-new tabs. + if lastIsLoadingObserved && !tab.isLoading { + lastLoadingStoppedAt = Date() + } + lastIsLoadingObserved = tab.isLoading + + globeFallbackWorkItem?.cancel() + globeFallbackWorkItem = nil + + // Only delay the globe fallback right after a navigation completes, when a favicon is likely to + // arrive soon. Otherwise (e.g. a brand-new tab), show the globe immediately. + let recentlyStoppedLoading: Bool = { + guard let t = lastLoadingStoppedAt else { return false } + return Date().timeIntervalSince(t) < 1.5 + }() + let shouldDelayGlobe = (tab.icon == "globe") && (tab.iconImageData == nil) && !tab.isLoading && recentlyStoppedLoading + if !shouldDelayGlobe { + showGlobeFallback = true + return + } + + showGlobeFallback = false + let work = DispatchWorkItem { + showGlobeFallback = true + } + globeFallbackWorkItem = work + // Give favicon fetches a little longer before showing the globe fallback to reduce brief flashes. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.90, execute: work) + } + + private func updateRenderedFaviconImage() { + guard renderedFaviconData != tab.iconImageData || + (renderedFaviconImage == nil && tab.iconImageData != nil) else { return } + renderedFaviconData = tab.iconImageData + renderedFaviconImage = TabItemStyling.resolvedFaviconImage( + existing: renderedFaviconImage, + incomingData: tab.iconImageData + ) + } + + private var accessibilityValue: String { + var parts: [String] = [] + if tab.isLoading { parts.append("Loading") } + if tab.isPinned { parts.append("Pinned") } + if tab.showsNotificationBadge { parts.append("Unread") } + if tab.isDirty { parts.append("Modified") } + if showsZoomIndicator { parts.append("Zoomed") } + return parts.joined(separator: ", ") + } + + @ViewBuilder + private var contextMenuContent: some View { + contextButton("Rename Tab…", action: .rename) + + if contextMenuState.hasCustomTitle { + contextButton("Remove Custom Tab Name", action: .clearName) + } + + Divider() + + contextButton("Close Tabs to Left", action: .closeToLeft) + .disabled(!contextMenuState.canCloseToLeft) + + contextButton("Close Tabs to Right", action: .closeToRight) + .disabled(!contextMenuState.canCloseToRight) + + contextButton("Close Other Tabs", action: .closeOthers) + .disabled(!contextMenuState.canCloseOthers) + + contextButton("Move Tab…", action: .move) + + if contextMenuState.isTerminal { + localizedContextButton( + "command.moveTabToLeftPane.title", + defaultValue: "Move to Left Pane", + action: .moveToLeftPane + ) + .disabled(!contextMenuState.canMoveToLeftPane) + + localizedContextButton( + "command.moveTabToRightPane.title", + defaultValue: "Move to Right Pane", + action: .moveToRightPane + ) + .disabled(!contextMenuState.canMoveToRightPane) + } + + Divider() + + contextButton("New Terminal Tab to Right", action: .newTerminalToRight) + + contextButton("New Browser Tab to Right", action: .newBrowserToRight) + + if contextMenuState.isBrowser { + Divider() + + contextButton("Reload Tab", action: .reload) + + contextButton("Duplicate Tab", action: .duplicate) + } + + Divider() + + if contextMenuState.hasSplits { + contextButton( + contextMenuState.isZoomed ? "Exit Zoom" : "Zoom Pane", + action: .toggleZoom + ) + } + + contextButton( + contextMenuState.isPinned ? "Unpin Tab" : "Pin Tab", + action: .togglePin + ) + + if contextMenuState.isUnread { + contextButton("Mark Tab as Read", action: .markAsRead) + .disabled(!contextMenuState.canMarkAsRead) + } else { + contextButton("Mark Tab as Unread", action: .markAsUnread) + .disabled(!contextMenuState.canMarkAsUnread) + } + } + + @ViewBuilder + private func contextButton(_ title: String, action: TabContextAction) -> some View { + if let shortcut = contextMenuState.shortcuts[action] { + Button(title) { + onContextAction(action) + } + .keyboardShortcut(shortcut) + } else { + Button(title) { + onContextAction(action) + } + } + } + + @ViewBuilder + private func localizedContextButton( + _ titleKey: String, + defaultValue: String, + action: TabContextAction + ) -> some View { + contextButton( + Bundle.module.localizedString(forKey: titleKey, value: defaultValue, table: nil), + action: action + ) + } + + // MARK: - Tab Background + + @ViewBuilder + private var tabBackground: some View { + ZStack(alignment: .top) { + // Background fill (hover) + if TabItemStyling.shouldShowHoverBackground(isHovered: isHovered, isSelected: isSelected) { + Rectangle() + .fill(TabBarColors.hoveredTabBackground(for: appearance)) + } else { + Color.clear + } + + // Top accent indicator for selected tab + if isSelected { + Rectangle() + .fill(Color.accentColor) + .frame(height: TabBarMetrics.activeIndicatorHeight) + } + + // Right border separator + HStack { + Spacer() + Rectangle() + .fill(TabBarColors.separator(for: appearance)) + .frame(width: 1) + } + } + } + + // MARK: - Close Button / Dirty Indicator + + @ViewBuilder + private var closeOrDirtyIndicator: some View { + ZStack { + // Dirty indicator (shown when dirty and not hovering, hidden for selected tab) + if (!isSelected && !isHovered && !isCloseHovered) && (tab.isDirty || tab.showsNotificationBadge) { + HStack(spacing: 2) { + if tab.showsNotificationBadge { + Circle() + .fill(TabBarColors.notificationBadge(for: appearance)) + .frame(width: TabBarMetrics.notificationBadgeSize, height: TabBarMetrics.notificationBadgeSize) + } + if tab.isDirty { + Circle() + .fill(TabBarColors.dirtyIndicator(for: appearance)) + .frame(width: TabBarMetrics.dirtyIndicatorSize, height: TabBarMetrics.dirtyIndicatorSize) + .saturation(saturation) + } + } + } + + if tab.isPinned { + if isSelected || isHovered || isCloseHovered || (!tab.isDirty && !tab.showsNotificationBadge) { + Image(systemName: "pin.fill") + .font(.system(size: TabBarMetrics.closeIconSize, weight: .semibold)) + .foregroundStyle(TabBarColors.inactiveText(for: appearance)) + .frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize) + .saturation(saturation) + } + } else if isSelected || isHovered || isCloseHovered { + // Close button (always visible on active tab, shown on hover for others) + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: TabBarMetrics.closeIconSize, weight: .semibold)) + .foregroundStyle( + isCloseHovered + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance) + ) + .frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize) + .background( + Circle() + .fill( + isCloseHovered + ? TabBarColors.hoveredTabBackground(for: appearance) + : .clear + ) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + isCloseHovered = hovering + } + .saturation(saturation) + } + } + .frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize) + .animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: isHovered) + .animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: isCloseHovered) + } +} + +private struct TabLoadingSpinner: View { + let size: CGFloat + let color: Color + + var body: some View { + TimelineView(.animation) { context in + let t = context.date.timeIntervalSinceReferenceDate + // 0.9s per revolution feels a bit snappier at tab-icon scale. + let angle = (t.truncatingRemainder(dividingBy: 0.9) / 0.9) * 360.0 + + ZStack { + Circle() + .stroke(color.opacity(0.20), lineWidth: ringWidth) + Circle() + .trim(from: 0.0, to: 0.28) + .stroke(color, style: StrokeStyle(lineWidth: ringWidth, lineCap: .round)) + .rotationEffect(.degrees(angle)) + } + .frame(width: size, height: size) + } + } + + private var ringWidth: CGFloat { + max(1.6, size * 0.14) + } +} + +private struct FaviconIconView: NSViewRepresentable { + let image: NSImage + + final class ContainerView: NSView { + let imageView = NSImageView(frame: .zero) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + imageView.imageScaling = .scaleProportionallyDown + imageView.imageAlignment = .alignCenter + imageView.animates = false + imageView.contentTintColor = nil + imageView.autoresizingMask = [.width, .height] + addSubview(imageView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + .zero + } + + override func layout() { + super.layout() + imageView.frame = bounds.integral + } + } + + func makeNSView(context: Context) -> ContainerView { + ContainerView(frame: .zero) + } + + func updateNSView(_ nsView: ContainerView, context: Context) { + image.isTemplate = false + if nsView.imageView.image !== image { + nsView.imageView.image = image + } + nsView.imageView.contentTintColor = nil + } +} + +private struct MiddleClickMonitorView: NSViewRepresentable { + let onMiddleClick: () -> Void + + final class Coordinator { + var onMiddleClick: (() -> Void)? + weak var view: NSView? + var monitor: Any? + + deinit { + if let monitor { + NSEvent.removeMonitor(monitor) + } + } + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.clear.cgColor + + context.coordinator.view = view + context.coordinator.onMiddleClick = onMiddleClick + + // Monitor only middle clicks so we don't break drag/reorder or normal selection. + let coordinator = context.coordinator + coordinator.monitor = NSEvent.addLocalMonitorForEvents(matching: [.otherMouseUp]) { [weak coordinator] event in + guard event.buttonNumber == 2 else { return event } + guard let coordinator, let v = coordinator.view, let w = v.window else { return event } + guard event.window === w else { return event } + + let p = v.convert(event.locationInWindow, from: nil) + guard v.bounds.contains(p) else { return event } + + coordinator.onMiddleClick?() + return nil // swallow so it doesn't also select the tab + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.view = nsView + context.coordinator.onMiddleClick = onMiddleClick + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitConfiguration.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitConfiguration.swift new file mode 100644 index 00000000000..c852b3fb1db --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitConfiguration.swift @@ -0,0 +1,243 @@ +import Foundation +import SwiftUI + +/// Controls how tab content views are managed when switching between tabs +public enum ContentViewLifecycle: Sendable { + /// Only the selected tab's content view is rendered. Other tabs' views are + /// destroyed and recreated when selected. This is memory efficient but loses + /// view state like scroll position, @State variables, and focus. + case recreateOnSwitch + + /// All tab content views are kept in the view hierarchy, with non-selected tabs + /// hidden. This preserves all view state (scroll position, @State, focus, etc.) + /// at the cost of higher memory usage. + case keepAllAlive +} + +/// Controls the position where new tabs are created +public enum NewTabPosition: Sendable { + /// Insert the new tab after the currently focused tab, + /// or at the end if there are no focused tabs. + case current + + /// Insert the new tab at the end of the tab list. + case end +} + +/// Configuration for the split tab bar appearance and behavior +public struct BonsplitConfiguration: Sendable { + + // MARK: - Behavior + + /// Whether to allow creating splits + public var allowSplits: Bool + + /// Whether to allow closing tabs + public var allowCloseTabs: Bool + + /// Whether to allow closing the last pane + public var allowCloseLastPane: Bool + + /// Whether to allow drag & drop reordering of tabs + public var allowTabReordering: Bool + + /// Whether to allow moving tabs between panes + public var allowCrossPaneTabMove: Bool + + /// Whether to automatically close empty panes + public var autoCloseEmptyPanes: Bool + + /// Controls how tab content views are managed when switching tabs + public var contentViewLifecycle: ContentViewLifecycle + + /// Controls where new tabs are inserted in the tab list + public var newTabPosition: NewTabPosition + + // MARK: - Appearance + + /// Tab bar appearance customization + public var appearance: Appearance + + // MARK: - Presets + + public static let `default` = BonsplitConfiguration() + + public static let singlePane = BonsplitConfiguration( + allowSplits: false, + allowCloseLastPane: false + ) + + public static let readOnly = BonsplitConfiguration( + allowSplits: false, + allowCloseTabs: false, + allowTabReordering: false, + allowCrossPaneTabMove: false + ) + + // MARK: - Initializer + + public init( + allowSplits: Bool = true, + allowCloseTabs: Bool = true, + allowCloseLastPane: Bool = false, + allowTabReordering: Bool = true, + allowCrossPaneTabMove: Bool = true, + autoCloseEmptyPanes: Bool = true, + contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch, + newTabPosition: NewTabPosition = .current, + appearance: Appearance = .default + ) { + self.allowSplits = allowSplits + self.allowCloseTabs = allowCloseTabs + self.allowCloseLastPane = allowCloseLastPane + self.allowTabReordering = allowTabReordering + self.allowCrossPaneTabMove = allowCrossPaneTabMove + self.autoCloseEmptyPanes = autoCloseEmptyPanes + self.contentViewLifecycle = contentViewLifecycle + self.newTabPosition = newTabPosition + self.appearance = appearance + } +} + +// MARK: - Appearance Configuration + +extension BonsplitConfiguration { + public struct SplitButtonTooltips: Sendable, Equatable { + public var newTerminal: String + public var newBrowser: String + public var splitRight: String + public var splitDown: String + + public static let `default` = SplitButtonTooltips() + + public init( + newTerminal: String = "New Terminal", + newBrowser: String = "New Browser", + splitRight: String = "Split Right", + splitDown: String = "Split Down" + ) { + self.newTerminal = newTerminal + self.newBrowser = newBrowser + self.splitRight = splitRight + self.splitDown = splitDown + } + } + + public struct Appearance: Sendable { + public struct ChromeColors: Sendable { + /// Optional hex color (`#RRGGBB` or `#RRGGBBAA`) for tab/pane chrome backgrounds. + /// When unset, Bonsplit uses native system colors. + public var backgroundHex: String? + + /// Optional hex color (`#RRGGBB` or `#RRGGBBAA`) for separators/dividers. + /// When unset, Bonsplit derives separators from the chrome background. + public var borderHex: String? + + public init( + backgroundHex: String? = nil, + borderHex: String? = nil + ) { + self.backgroundHex = backgroundHex + self.borderHex = borderHex + } + } + + // MARK: - Tab Bar + + /// Height of the tab bar + public var tabBarHeight: CGFloat + + // MARK: - Tabs + + /// Minimum width of a tab + public var tabMinWidth: CGFloat + + /// Maximum width of a tab + public var tabMaxWidth: CGFloat + + /// Spacing between tabs + public var tabSpacing: CGFloat + + // MARK: - Split View + + /// Minimum width of a pane + public var minimumPaneWidth: CGFloat + + /// Minimum height of a pane + public var minimumPaneHeight: CGFloat + + /// Whether to show split buttons in the tab bar + public var showSplitButtons: Bool + + /// When true, split buttons are only visible on hover + public var splitButtonsOnHover: Bool + + /// Extra leading inset for the tab bar (e.g. for traffic light buttons when sidebar is collapsed) + public var tabBarLeadingInset: CGFloat + + /// Tooltip text for the tab bar's right-side action buttons + public var splitButtonTooltips: SplitButtonTooltips + + // MARK: - Animations + + /// Duration of animations + public var animationDuration: Double + + /// Whether to enable animations + public var enableAnimations: Bool + + // MARK: - Theme Overrides + + /// Optional color overrides for tab/pane chrome. + public var chromeColors: ChromeColors + + // MARK: - Presets + + public static let `default` = Appearance() + + public static let compact = Appearance( + tabBarHeight: 28, + tabMinWidth: 100, + tabMaxWidth: 160 + ) + + public static let spacious = Appearance( + tabBarHeight: 38, + tabMinWidth: 160, + tabMaxWidth: 280, + tabSpacing: 2 + ) + + // MARK: - Initializer + + public init( + tabBarHeight: CGFloat = 33, + tabMinWidth: CGFloat = 140, + tabMaxWidth: CGFloat = 220, + tabSpacing: CGFloat = 0, + minimumPaneWidth: CGFloat = 100, + minimumPaneHeight: CGFloat = 100, + showSplitButtons: Bool = true, + splitButtonsOnHover: Bool = false, + tabBarLeadingInset: CGFloat = 0, + splitButtonTooltips: SplitButtonTooltips = .default, + animationDuration: Double = 0.15, + enableAnimations: Bool = true, + chromeColors: ChromeColors = .init() + ) { + self.tabBarHeight = tabBarHeight + self.tabMinWidth = tabMinWidth + self.tabMaxWidth = tabMaxWidth + self.tabSpacing = tabSpacing + self.minimumPaneWidth = minimumPaneWidth + self.minimumPaneHeight = minimumPaneHeight + self.showSplitButtons = showSplitButtons + self.splitButtonsOnHover = splitButtonsOnHover + self.tabBarLeadingInset = tabBarLeadingInset + self.splitButtonTooltips = splitButtonTooltips + self.animationDuration = animationDuration + self.enableAnimations = enableAnimations + self.chromeColors = chromeColors + } + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift new file mode 100644 index 00000000000..cf534fcda4a --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift @@ -0,0 +1,816 @@ +import Foundation +import SwiftUI + +/// Main controller for the split tab bar system +@MainActor +@Observable +public final class BonsplitController { + + public struct ExternalTabDropRequest { + public enum Destination { + case insert(targetPane: PaneID, targetIndex: Int?) + case split(targetPane: PaneID, orientation: SplitOrientation, insertFirst: Bool) + } + + public let tabId: TabID + public let sourcePaneId: PaneID + public let destination: Destination + + public init(tabId: TabID, sourcePaneId: PaneID, destination: Destination) { + self.tabId = tabId + self.sourcePaneId = sourcePaneId + self.destination = destination + } + } + + // MARK: - Delegate + + /// Delegate for receiving callbacks about tab bar events + public weak var delegate: BonsplitDelegate? + + // MARK: - Configuration + + /// Configuration for behavior and appearance + public var configuration: BonsplitConfiguration + + /// When false, drop delegates reject all drags. Set to false for inactive workspaces + /// so their views (kept alive in a ZStack for state preservation) don't intercept drags + /// meant for the active workspace. + @ObservationIgnored public var isInteractive: Bool = true { + didSet { internalController.isInteractive = isInteractive } + } + + /// Handler for file/URL drops from external apps (e.g., Finder). + /// Called when files are dropped onto a pane's content area. + /// Return `true` if the drop was handled. + @ObservationIgnored public var onFileDrop: ((_ urls: [URL], _ paneId: PaneID) -> Bool)? { + didSet { internalController.onFileDrop = onFileDrop } + } + + /// Handler for tab drops originating from another Bonsplit controller (e.g. another workspace/window). + /// Return `true` when the drop has been handled by the host application. + @ObservationIgnored public var onExternalTabDrop: ((ExternalTabDropRequest) -> Bool)? + + /// Called when the user explicitly requests to close a tab from the tab strip UI. + /// Internal host-driven closes should not use this hook. + @ObservationIgnored public var onTabCloseRequest: ((_ tabId: TabID, _ paneId: PaneID) -> Void)? + + // MARK: - Internal State + + internal var internalController: SplitViewController + + // MARK: - Initialization + + /// Create a new controller with the specified configuration + public init(configuration: BonsplitConfiguration = .default) { + self.configuration = configuration + self.internalController = SplitViewController() + } + + // MARK: - Tab Operations + + /// Create a new tab in the focused pane (or specified pane) + /// - Parameters: + /// - title: The tab title + /// - icon: Optional SF Symbol name for the tab icon + /// - iconImageData: Optional image data (PNG recommended) for the tab icon. When present, takes precedence over `icon`. + /// - kind: Consumer-defined tab kind identifier (e.g. "terminal", "browser") + /// - hasCustomTitle: Whether the tab title came from a custom user override + /// - isDirty: Whether the tab shows a dirty indicator + /// - showsNotificationBadge: Whether the tab shows an "unread/activity" badge + /// - isLoading: Whether the tab shows an activity/loading indicator (e.g. spinning icon) + /// - isPinned: Whether the tab should be treated as pinned + /// - pane: Optional pane to add the tab to (defaults to focused pane) + /// - Returns: The TabID of the created tab, or nil if creation was vetoed by delegate + @discardableResult + public func createTab( + title: String, + hasCustomTitle: Bool = false, + icon: String? = "doc.text", + iconImageData: Data? = nil, + kind: String? = nil, + isDirty: Bool = false, + showsNotificationBadge: Bool = false, + isLoading: Bool = false, + isPinned: Bool = false, + inPane pane: PaneID? = nil + ) -> TabID? { + let tabId = TabID() + let tab = Tab( + id: tabId, + title: title, + hasCustomTitle: hasCustomTitle, + icon: icon, + iconImageData: iconImageData, + kind: kind, + isDirty: isDirty, + showsNotificationBadge: showsNotificationBadge, + isLoading: isLoading, + isPinned: isPinned + ) + let targetPane = pane ?? focusedPaneId ?? PaneID(id: internalController.rootNode.allPaneIds.first!.id) + + // Check with delegate + if delegate?.splitTabBar(self, shouldCreateTab: tab, inPane: targetPane) == false { + return nil + } + + // Calculate insertion index based on configuration + let insertIndex: Int? + switch configuration.newTabPosition { + case .current: + // Insert after the currently selected tab + if let paneState = internalController.rootNode.findPane(PaneID(id: targetPane.id)), + let selectedTabId = paneState.selectedTabId, + let currentIndex = paneState.tabs.firstIndex(where: { $0.id == selectedTabId }) { + insertIndex = currentIndex + 1 + } else { + // No selected tab, append to end + insertIndex = nil + } + case .end: + insertIndex = nil + } + + // Create internal TabItem + let tabItem = TabItem( + id: tabId.id, + title: title, + hasCustomTitle: hasCustomTitle, + icon: icon, + iconImageData: iconImageData, + kind: kind, + isDirty: isDirty, + showsNotificationBadge: showsNotificationBadge, + isLoading: isLoading, + isPinned: isPinned + ) + internalController.addTab(tabItem, toPane: PaneID(id: targetPane.id), atIndex: insertIndex) + + // Notify delegate + delegate?.splitTabBar(self, didCreateTab: tab, inPane: targetPane) + + return tabId + } + + /// Request the delegate to create a new tab of the given kind in a pane. + /// The delegate is responsible for the actual creation logic. + public func requestNewTab(kind: String, inPane pane: PaneID) { + delegate?.splitTabBar(self, didRequestNewTab: kind, inPane: pane) + } + + /// Request the delegate to handle a tab context-menu action. + public func requestTabContextAction(_ action: TabContextAction, for tabId: TabID, inPane pane: PaneID) { + guard let tab = tab(tabId) else { return } + delegate?.splitTabBar(self, didRequestTabContextAction: action, for: tab, inPane: pane) + } + + /// Update an existing tab's metadata + /// - Parameters: + /// - tabId: The tab to update + /// - title: New title (pass nil to keep current) + /// - icon: New icon (pass nil to keep current, pass .some(nil) to remove icon) + /// - iconImageData: New icon image data (pass nil to keep current, pass .some(nil) to remove) + /// - kind: New tab kind (pass nil to keep current, pass .some(nil) to clear) + /// - hasCustomTitle: New custom-title state (pass nil to keep current) + /// - isDirty: New dirty state (pass nil to keep current) + /// - showsNotificationBadge: New badge state (pass nil to keep current) + /// - isLoading: New loading/busy state (pass nil to keep current) + /// - isPinned: New pinned state (pass nil to keep current) + public func updateTab( + _ tabId: TabID, + title: String? = nil, + icon: String?? = nil, + iconImageData: Data?? = nil, + kind: String?? = nil, + hasCustomTitle: Bool? = nil, + isDirty: Bool? = nil, + showsNotificationBadge: Bool? = nil, + isLoading: Bool? = nil, + isPinned: Bool? = nil + ) { + guard let (pane, tabIndex) = findTabInternal(tabId) else { return } + + if let title = title { + pane.tabs[tabIndex].title = title + } + if let icon = icon { + pane.tabs[tabIndex].icon = icon + } + if let iconImageData = iconImageData { + pane.tabs[tabIndex].iconImageData = iconImageData + } + if let kind = kind { + pane.tabs[tabIndex].kind = kind + } + if let hasCustomTitle = hasCustomTitle { + pane.tabs[tabIndex].hasCustomTitle = hasCustomTitle + } + if let isDirty = isDirty { + pane.tabs[tabIndex].isDirty = isDirty + } + if let showsNotificationBadge = showsNotificationBadge { + pane.tabs[tabIndex].showsNotificationBadge = showsNotificationBadge + } + if let isLoading = isLoading { + pane.tabs[tabIndex].isLoading = isLoading + } + if let isPinned = isPinned { + pane.tabs[tabIndex].isPinned = isPinned + } + } + + /// Close a tab by ID + /// - Parameter tabId: The tab to close + /// - Returns: true if the tab was closed, false if vetoed by delegate + @discardableResult + public func closeTab(_ tabId: TabID) -> Bool { + guard let (pane, tabIndex) = findTabInternal(tabId) else { return false } + return closeTab(tabId, with: tabIndex, in: pane) + } + + /// Close a tab by ID in a specific pane. + /// - Parameter tabId: The tab to close + /// - Parameter paneId: The pane in which to close the tab + public func closeTab(_ tabId: TabID, inPane paneId: PaneID) -> Bool { + guard let pane = internalController.rootNode.findPane(paneId), + let tabIndex = pane.tabs.firstIndex(where: { $0.id == tabId.id }) else { + return false + } + + return closeTab(tabId, with: tabIndex, in: pane) + } + + /// Internal helper to close a tab given its index in a pane + /// - Parameter tabId: The tab to close + /// - Parameter tabIndex: The position of the tab within the pane + /// - Parameter pane: The pane in which to close the tab + private func closeTab(_ tabId: TabID, with tabIndex: Int, in pane: PaneState) -> Bool { + let tabItem = pane.tabs[tabIndex] + let tab = Tab(from: tabItem) + let paneId = pane.id + + // Check with delegate + if delegate?.splitTabBar(self, shouldCloseTab: tab, inPane: paneId) == false { + return false + } + + internalController.closeTab(tabId.id, inPane: pane.id) + + // Notify delegate + delegate?.splitTabBar(self, didCloseTab: tabId, fromPane: paneId) + notifyGeometryChange() + + return true + } + + /// Select a tab by ID + /// - Parameter tabId: The tab to select + public func selectTab(_ tabId: TabID) { + guard let (pane, tabIndex) = findTabInternal(tabId) else { return } + + pane.selectTab(tabId.id) + internalController.focusPane(pane.id) + + // Notify delegate + let tab = Tab(from: pane.tabs[tabIndex]) + delegate?.splitTabBar(self, didSelectTab: tab, inPane: pane.id) + } + + /// Move a tab to a specific pane (and optional index) inside this controller. + /// - Parameters: + /// - tabId: The tab to move. + /// - targetPaneId: Destination pane. + /// - index: Optional destination index. When nil, appends at the end. + /// - Returns: true if moved. + @discardableResult + public func moveTab(_ tabId: TabID, toPane targetPaneId: PaneID, atIndex index: Int? = nil) -> Bool { + guard let (sourcePane, sourceIndex) = findTabInternal(tabId) else { return false } + guard let targetPane = internalController.rootNode.findPane(PaneID(id: targetPaneId.id)) else { return false } + + let tabItem = sourcePane.tabs[sourceIndex] + let movedTab = Tab(from: tabItem) + let sourcePaneId = sourcePane.id + + if sourcePaneId == targetPane.id { + // Reorder within same pane. + let destinationIndex: Int = { + if let index { return max(0, min(index, sourcePane.tabs.count)) } + return sourcePane.tabs.count + }() + sourcePane.moveTab(from: sourceIndex, to: destinationIndex) + sourcePane.selectTab(tabItem.id) + internalController.focusPane(sourcePane.id) + delegate?.splitTabBar(self, didSelectTab: movedTab, inPane: sourcePane.id) + notifyGeometryChange() + return true + } + + internalController.moveTab(tabItem, from: sourcePaneId, to: targetPane.id, atIndex: index) + delegate?.splitTabBar(self, didMoveTab: movedTab, fromPane: sourcePaneId, toPane: targetPane.id) + notifyGeometryChange() + return true + } + + /// Reorder a tab within its pane. + /// - Parameters: + /// - tabId: The tab to reorder. + /// - toIndex: Destination index. + /// - Returns: true if reordered. + @discardableResult + public func reorderTab(_ tabId: TabID, toIndex: Int) -> Bool { + guard let (pane, sourceIndex) = findTabInternal(tabId) else { return false } + let destinationIndex = max(0, min(toIndex, pane.tabs.count)) + pane.moveTab(from: sourceIndex, to: destinationIndex) + pane.selectTab(tabId.id) + internalController.focusPane(pane.id) + if let tabIndex = pane.tabs.firstIndex(where: { $0.id == tabId.id }) { + let tab = Tab(from: pane.tabs[tabIndex]) + delegate?.splitTabBar(self, didSelectTab: tab, inPane: pane.id) + } + notifyGeometryChange() + return true + } + + /// Move to previous tab in focused pane + public func selectPreviousTab() { + internalController.selectPreviousTab() + notifyTabSelection() + } + + /// Move to next tab in focused pane + public func selectNextTab() { + internalController.selectNextTab() + notifyTabSelection() + } + + // MARK: - Split Operations + + /// Split the focused pane (or specified pane) + /// - Parameters: + /// - paneId: Optional pane to split (defaults to focused pane) + /// - orientation: Direction to split (horizontal = side-by-side, vertical = stacked) + /// - tab: Optional tab to add to the new pane + /// - Returns: The new pane ID, or nil if vetoed by delegate + @discardableResult + public func splitPane( + _ paneId: PaneID? = nil, + orientation: SplitOrientation, + withTab tab: Tab? = nil + ) -> PaneID? { + guard configuration.allowSplits else { return nil } + + let targetPaneId = paneId ?? focusedPaneId + guard let targetPaneId else { return nil } + + // Check with delegate + if delegate?.splitTabBar(self, shouldSplitPane: targetPaneId, orientation: orientation) == false { + return nil + } + + let internalTab: TabItem? + if let tab { + internalTab = TabItem( + id: tab.id.id, + title: tab.title, + hasCustomTitle: tab.hasCustomTitle, + icon: tab.icon, + iconImageData: tab.iconImageData, + kind: tab.kind, + isDirty: tab.isDirty, + showsNotificationBadge: tab.showsNotificationBadge, + isLoading: tab.isLoading, + isPinned: tab.isPinned + ) + } else { + internalTab = nil + } + + // Perform split + internalController.splitPane( + PaneID(id: targetPaneId.id), + orientation: orientation, + with: internalTab + ) + + // Find new pane (will be focused after split) + let newPaneId = focusedPaneId! + + // Notify delegate + delegate?.splitTabBar(self, didSplitPane: targetPaneId, newPane: newPaneId, orientation: orientation) + + notifyGeometryChange() + + return newPaneId + } + + /// Split a pane and place a specific tab in the newly created pane, choosing which side to insert on. + /// + /// This is like `splitPane(_:orientation:withTab:)`, but allows choosing left/top vs right/bottom insertion + /// without needing to create then move a tab. + /// + /// - Parameters: + /// - paneId: Optional pane to split (defaults to focused pane). + /// - orientation: Direction to split (horizontal = side-by-side, vertical = stacked). + /// - tab: The tab to add to the new pane. + /// - insertFirst: If true, insert the new pane first (left/top). Otherwise insert second (right/bottom). + /// - Returns: The new pane ID, or nil if vetoed by delegate. + @discardableResult + public func splitPane( + _ paneId: PaneID? = nil, + orientation: SplitOrientation, + withTab tab: Tab, + insertFirst: Bool + ) -> PaneID? { + guard configuration.allowSplits else { return nil } + + let targetPaneId = paneId ?? focusedPaneId + guard let targetPaneId else { return nil } + + // Check with delegate + if delegate?.splitTabBar(self, shouldSplitPane: targetPaneId, orientation: orientation) == false { + return nil + } + + let internalTab = TabItem( + id: tab.id.id, + title: tab.title, + hasCustomTitle: tab.hasCustomTitle, + icon: tab.icon, + iconImageData: tab.iconImageData, + kind: tab.kind, + isDirty: tab.isDirty, + showsNotificationBadge: tab.showsNotificationBadge, + isLoading: tab.isLoading, + isPinned: tab.isPinned + ) + + // Perform split with insertion side. + internalController.splitPaneWithTab( + PaneID(id: targetPaneId.id), + orientation: orientation, + tab: internalTab, + insertFirst: insertFirst + ) + + let newPaneId = focusedPaneId! + + // Notify delegate + delegate?.splitTabBar(self, didSplitPane: targetPaneId, newPane: newPaneId, orientation: orientation) + + notifyGeometryChange() + + return newPaneId + } + + /// Split a pane by moving an existing tab into the new pane. + /// + /// This mirrors the "drag a tab to a pane edge to create a split" interaction: + /// the tab is removed from its source pane first, then inserted into the newly + /// created pane on the chosen edge. + /// + /// - Parameters: + /// - paneId: Optional target pane to split (defaults to the tab's current pane). + /// - orientation: Direction to split (horizontal = side-by-side, vertical = stacked). + /// - tabId: The existing tab to move into the new pane. + /// - insertFirst: If true, the new pane is inserted first (left/top). Otherwise it is inserted second (right/bottom). + /// - Returns: The new pane ID, or nil if the tab couldn't be found or the split was vetoed. + @discardableResult + public func splitPane( + _ paneId: PaneID? = nil, + orientation: SplitOrientation, + movingTab tabId: TabID, + insertFirst: Bool + ) -> PaneID? { + guard configuration.allowSplits else { return nil } + + // Find the existing tab and its source pane. + guard let (sourcePane, tabIndex) = findTabInternal(tabId) else { return nil } + let tabItem = sourcePane.tabs[tabIndex] + + // Default target to the tab's current pane to match edge-drop behavior on the source pane. + let targetPaneId = paneId ?? sourcePane.id + + // Check with delegate + if delegate?.splitTabBar(self, shouldSplitPane: targetPaneId, orientation: orientation) == false { + return nil + } + + // Remove from source first. + sourcePane.removeTab(tabItem.id) + + if sourcePane.tabs.isEmpty { + if sourcePane.id == targetPaneId { + // Keep a placeholder tab so the original pane isn't left "tabless". + // This makes the empty side closable via tab close, and avoids apps + // needing to special-case empty panes. + sourcePane.addTab(TabItem(title: "Empty", icon: nil), select: true) + } else if internalController.rootNode.allPaneIds.count > 1 { + // If the source pane is now empty, close it (unless it's also the split target). + internalController.closePane(sourcePane.id) + } + } + + // Perform split with the moved tab. + internalController.splitPaneWithTab( + PaneID(id: targetPaneId.id), + orientation: orientation, + tab: tabItem, + insertFirst: insertFirst + ) + + let newPaneId = focusedPaneId! + + // Notify delegate + delegate?.splitTabBar(self, didSplitPane: targetPaneId, newPane: newPaneId, orientation: orientation) + + notifyGeometryChange() + + return newPaneId + } + + /// Close a specific pane + /// - Parameter paneId: The pane to close + /// - Returns: true if the pane was closed, false if vetoed by delegate + @discardableResult + public func closePane(_ paneId: PaneID) -> Bool { + // Don't close if it's the last pane and not allowed + if !configuration.allowCloseLastPane && internalController.rootNode.allPaneIds.count <= 1 { + return false + } + + // Check with delegate + if delegate?.splitTabBar(self, shouldClosePane: paneId) == false { + return false + } + + internalController.closePane(PaneID(id: paneId.id)) + + // Notify delegate + delegate?.splitTabBar(self, didClosePane: paneId) + + notifyGeometryChange() + + return true + } + + // MARK: - Focus Management + + /// Currently focused pane ID + public var focusedPaneId: PaneID? { + guard let internalId = internalController.focusedPaneId else { return nil } + return internalId + } + + /// Focus a specific pane + public func focusPane(_ paneId: PaneID) { + internalController.focusPane(PaneID(id: paneId.id)) + delegate?.splitTabBar(self, didFocusPane: paneId) + } + + /// Navigate focus in a direction + public func navigateFocus(direction: NavigationDirection) { + internalController.navigateFocus(direction: direction) + if let focusedPaneId { + delegate?.splitTabBar(self, didFocusPane: focusedPaneId) + } + } + + /// Find the closest pane in the requested direction from the given pane. + public func adjacentPane(to paneId: PaneID, direction: NavigationDirection) -> PaneID? { + internalController.adjacentPane(to: paneId, direction: direction) + } + + // MARK: - Split Zoom + + /// Currently zoomed pane ID, if any. + public var zoomedPaneId: PaneID? { + internalController.zoomedPaneId + } + + public var isSplitZoomed: Bool { + internalController.zoomedPaneId != nil + } + + @discardableResult + public func clearPaneZoom() -> Bool { + internalController.clearPaneZoom() + } + + /// Toggle zoom for a pane. When zoomed, only that pane is rendered in the split area. + /// Passing nil toggles the currently focused pane. + @discardableResult + public func togglePaneZoom(inPane paneId: PaneID? = nil) -> Bool { + let targetPaneId = paneId ?? focusedPaneId + guard let targetPaneId else { return false } + return internalController.togglePaneZoom(targetPaneId) + } + + // MARK: - Context Menu Shortcut Hints + + /// Keyboard shortcuts to display in tab context menus, keyed by context action. + /// Set by the host app to sync with its customizable keyboard shortcut settings. + public var contextMenuShortcuts: [TabContextAction: KeyboardShortcut] = [:] + + // MARK: - Query Methods + + /// Get all tab IDs + public var allTabIds: [TabID] { + internalController.rootNode.allPanes.flatMap { pane in + pane.tabs.map { TabID(id: $0.id) } + } + } + + /// Get all pane IDs + public var allPaneIds: [PaneID] { + internalController.rootNode.allPaneIds + } + + /// Get tab metadata by ID + public func tab(_ tabId: TabID) -> Tab? { + guard let (pane, tabIndex) = findTabInternal(tabId) else { return nil } + return Tab(from: pane.tabs[tabIndex]) + } + + /// Get tabs in a specific pane + public func tabs(inPane paneId: PaneID) -> [Tab] { + guard let pane = internalController.rootNode.findPane(PaneID(id: paneId.id)) else { + return [] + } + return pane.tabs.map { Tab(from: $0) } + } + + /// Get selected tab in a pane + public func selectedTab(inPane paneId: PaneID) -> Tab? { + guard let pane = internalController.rootNode.findPane(PaneID(id: paneId.id)), + let selected = pane.selectedTab else { + return nil + } + return Tab(from: selected) + } + + // MARK: - Geometry Query API + + /// Get current layout snapshot with pixel coordinates + public func layoutSnapshot() -> LayoutSnapshot { + let containerFrame = internalController.containerFrame + let paneBounds = internalController.rootNode.computePaneBounds() + + let paneGeometries = paneBounds.map { bounds -> PaneGeometry in + let pane = internalController.rootNode.findPane(bounds.paneId) + let pixelFrame = PixelRect( + x: Double(bounds.bounds.minX * containerFrame.width + containerFrame.origin.x), + y: Double(bounds.bounds.minY * containerFrame.height + containerFrame.origin.y), + width: Double(bounds.bounds.width * containerFrame.width), + height: Double(bounds.bounds.height * containerFrame.height) + ) + return PaneGeometry( + paneId: bounds.paneId.id.uuidString, + frame: pixelFrame, + selectedTabId: pane?.selectedTabId?.uuidString, + tabIds: pane?.tabs.map { $0.id.uuidString } ?? [] + ) + } + + return LayoutSnapshot( + containerFrame: PixelRect(from: containerFrame), + panes: paneGeometries, + focusedPaneId: focusedPaneId?.id.uuidString, + timestamp: Date().timeIntervalSince1970 + ) + } + + /// Get full tree structure for external consumption + public func treeSnapshot() -> ExternalTreeNode { + let containerFrame = internalController.containerFrame + return buildExternalTree(from: internalController.rootNode, containerFrame: containerFrame) + } + + private func buildExternalTree(from node: SplitNode, containerFrame: CGRect, bounds: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1)) -> ExternalTreeNode { + switch node { + case .pane(let paneState): + let pixelFrame = PixelRect( + x: Double(bounds.minX * containerFrame.width + containerFrame.origin.x), + y: Double(bounds.minY * containerFrame.height + containerFrame.origin.y), + width: Double(bounds.width * containerFrame.width), + height: Double(bounds.height * containerFrame.height) + ) + let tabs = paneState.tabs.map { ExternalTab(id: $0.id.uuidString, title: $0.title) } + let paneNode = ExternalPaneNode( + id: paneState.id.id.uuidString, + frame: pixelFrame, + tabs: tabs, + selectedTabId: paneState.selectedTabId?.uuidString + ) + return .pane(paneNode) + + case .split(let splitState): + let dividerPos = splitState.dividerPosition + let firstBounds: CGRect + let secondBounds: CGRect + + switch splitState.orientation { + case .horizontal: + firstBounds = CGRect(x: bounds.minX, y: bounds.minY, + width: bounds.width * dividerPos, height: bounds.height) + secondBounds = CGRect(x: bounds.minX + bounds.width * dividerPos, y: bounds.minY, + width: bounds.width * (1 - dividerPos), height: bounds.height) + case .vertical: + firstBounds = CGRect(x: bounds.minX, y: bounds.minY, + width: bounds.width, height: bounds.height * dividerPos) + secondBounds = CGRect(x: bounds.minX, y: bounds.minY + bounds.height * dividerPos, + width: bounds.width, height: bounds.height * (1 - dividerPos)) + } + + let splitNode = ExternalSplitNode( + id: splitState.id.uuidString, + orientation: splitState.orientation == .horizontal ? "horizontal" : "vertical", + dividerPosition: Double(splitState.dividerPosition), + first: buildExternalTree(from: splitState.first, containerFrame: containerFrame, bounds: firstBounds), + second: buildExternalTree(from: splitState.second, containerFrame: containerFrame, bounds: secondBounds) + ) + return .split(splitNode) + } + } + + /// Check if a split exists by ID + public func findSplit(_ splitId: UUID) -> Bool { + return internalController.findSplit(splitId) != nil + } + + // MARK: - Geometry Update API + + /// Set divider position for a split node (0.0-1.0) + /// - Parameters: + /// - position: The new divider position (clamped to 0.1-0.9) + /// - splitId: The UUID of the split to update + /// - fromExternal: Set to true to suppress outgoing notifications (prevents loops) + /// - Returns: true if the split was found and updated + @discardableResult + public func setDividerPosition(_ position: CGFloat, forSplit splitId: UUID, fromExternal: Bool = false) -> Bool { + guard let split = internalController.findSplit(splitId) else { return false } + + if fromExternal { + internalController.isExternalUpdateInProgress = true + } + + // Clamp position to valid range + let clampedPosition = min(max(position, 0.1), 0.9) + split.dividerPosition = clampedPosition + + if fromExternal { + // Use a slight delay to allow the UI to update before re-enabling notifications + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.internalController.isExternalUpdateInProgress = false + } + } + + return true + } + + /// Update container frame (called when window moves/resizes) + public func setContainerFrame(_ frame: CGRect) { + internalController.containerFrame = frame + } + + /// Notify geometry change to delegate (internal use) + /// - Parameter isDragging: Whether the change is due to active divider dragging + internal func notifyGeometryChange(isDragging: Bool = false) { + guard !internalController.isExternalUpdateInProgress else { return } + + // If dragging, check if delegate wants notifications during drag + if isDragging { + let shouldNotify = delegate?.splitTabBar(self, shouldNotifyDuringDrag: true) ?? false + guard shouldNotify else { return } + } + + if isDragging { + // Debounce drag updates to avoid flooding delegates during divider moves. + let now = Date().timeIntervalSince1970 + let debounceInterval: TimeInterval = 0.05 + guard now - internalController.lastGeometryNotificationTime >= debounceInterval else { return } + internalController.lastGeometryNotificationTime = now + } + + let snapshot = layoutSnapshot() + delegate?.splitTabBar(self, didChangeGeometry: snapshot) + } + + // MARK: - Private Helpers + + private func findTabInternal(_ tabId: TabID) -> (PaneState, Int)? { + for pane in internalController.rootNode.allPanes { + if let index = pane.tabs.firstIndex(where: { $0.id == tabId.id }) { + return (pane, index) + } + } + return nil + } + + private func notifyTabSelection() { + guard let pane = internalController.focusedPane, + let tabItem = pane.selectedTab else { return } + let tab = Tab(from: tabItem) + delegate?.splitTabBar(self, didSelectTab: tab, inPane: pane.id) + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitDebugCounters.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitDebugCounters.swift new file mode 100644 index 00000000000..441603d411a --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitDebugCounters.swift @@ -0,0 +1,19 @@ +import Foundation + +#if DEBUG +/// Debug-only counters for Bonsplit internal behavior. +/// +/// These are intended for automated tests (via cmuxterm's debug socket) to +/// detect transient structural updates that can cause visible flashes. +public enum BonsplitDebugCounters { + public private(set) static var arrangedSubviewUnderflowCount: Int = 0 + + public static func reset() { + arrangedSubviewUnderflowCount = 0 + } + + internal static func recordArrangedSubviewUnderflow() { + arrangedSubviewUnderflowCount += 1 + } +} +#endif diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitDelegate.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitDelegate.swift new file mode 100644 index 00000000000..95ef1f1acd5 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitDelegate.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Protocol for receiving callbacks about tab bar events +public protocol BonsplitDelegate: AnyObject { + // MARK: - Tab Lifecycle (Veto Operations) + + /// Called when a new tab is about to be created. + /// Return `false` to prevent creation. + func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Tab, inPane pane: PaneID) -> Bool + + /// Called when a tab is about to be closed. + /// Return `false` to prevent closing (e.g., prompt to save unsaved changes). + func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Tab, inPane pane: PaneID) -> Bool + + // MARK: - Tab Lifecycle (Notifications) + + /// Called after a tab has been created. + func splitTabBar(_ controller: BonsplitController, didCreateTab tab: Tab, inPane pane: PaneID) + + /// Called after a tab has been closed. + func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) + + /// Called when a tab is selected. + func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Tab, inPane pane: PaneID) + + /// Called when a tab is moved between panes. + func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Tab, fromPane source: PaneID, toPane destination: PaneID) + + // MARK: - Split Lifecycle (Veto Operations) + + /// Called when a split is about to be created. + /// Return `false` to prevent the split. + func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool + + /// Called when a pane is about to be closed. + /// Return `false` to prevent closing. + func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool + + // MARK: - Split Lifecycle (Notifications) + + /// Called after a split has been created. + func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) + + /// Called after a pane has been closed. + func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) + + // MARK: - Focus + + /// Called when focus changes to a different pane. + func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) + + // MARK: - New Tab Request + + /// Called when the user clicks a "new tab" action in the tab bar. + /// The `kind` string identifies the type of tab (e.g. "terminal", "browser"). + func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) + + /// Called when the user triggers an action from a tab's context menu. + func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Tab, inPane pane: PaneID) + + // MARK: - Geometry + + /// Called when any pane geometry changes (resize, split, close) + func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) + + /// Called to check if notifications should be sent during divider drag (opt-in for real-time sync) + func splitTabBar(_ controller: BonsplitController, shouldNotifyDuringDrag: Bool) -> Bool +} + +// MARK: - Default Implementations (all methods optional) + +public extension BonsplitDelegate { + func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Tab, inPane pane: PaneID) -> Bool { true } + func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Tab, inPane pane: PaneID) -> Bool { true } + func splitTabBar(_ controller: BonsplitController, didCreateTab tab: Tab, inPane pane: PaneID) {} + func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) {} + func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Tab, inPane pane: PaneID) {} + func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Tab, fromPane source: PaneID, toPane destination: PaneID) {} + func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { true } + 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, didFocusPane pane: PaneID) {} + func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) {} + func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Tab, inPane pane: PaneID) {} + func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) {} + func splitTabBar(_ controller: BonsplitController, shouldNotifyDuringDrag: Bool) -> Bool { false } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitView.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitView.swift new file mode 100644 index 00000000000..cd4ed201809 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +/// Main entry point for the Bonsplit library +/// +/// Usage: +/// ```swift +/// struct MyApp: View { +/// @State private var controller = BonsplitController() +/// +/// var body: some View { +/// BonsplitView(controller: controller) { tab, paneId in +/// MyContentView(for: tab) +/// .onTapGesture { controller.focusPane(paneId) } +/// } emptyPane: { paneId in +/// Text("Empty pane") +/// } +/// } +/// } +/// ``` +public struct BonsplitView: View { + @Bindable private var controller: BonsplitController + private let contentBuilder: (Tab, PaneID) -> Content + private let emptyPaneBuilder: (PaneID) -> EmptyContent + + /// Initialize with a controller, content builder, and empty pane builder + /// - Parameters: + /// - controller: The BonsplitController managing the tab state + /// - content: A ViewBuilder closure that provides content for each tab. Receives the tab and pane ID. + /// - emptyPane: A ViewBuilder closure that provides content for empty panes + public init( + controller: BonsplitController, + @ViewBuilder content: @escaping (Tab, PaneID) -> Content, + @ViewBuilder emptyPane: @escaping (PaneID) -> EmptyContent + ) { + self.controller = controller + self.contentBuilder = content + self.emptyPaneBuilder = emptyPane + } + + public var body: some View { + SplitViewContainer( + contentBuilder: { tabItem, paneId in + contentBuilder(Tab(from: tabItem), PaneID(id: paneId.id)) + }, + emptyPaneBuilder: { internalPaneId in + emptyPaneBuilder(PaneID(id: internalPaneId.id)) + }, + appearance: controller.configuration.appearance, + showSplitButtons: controller.configuration.allowSplits && controller.configuration.appearance.showSplitButtons, + contentViewLifecycle: controller.configuration.contentViewLifecycle, + onGeometryChange: { [weak controller] isDragging in + controller?.notifyGeometryChange(isDragging: isDragging) + }, + enableAnimations: controller.configuration.appearance.enableAnimations, + animationDuration: controller.configuration.appearance.animationDuration + ) + .environment(controller) + .environment(controller.internalController) + } +} + +// MARK: - Convenience initializer with default empty view + +extension BonsplitView where EmptyContent == DefaultEmptyPaneView { + /// Initialize with a controller and content builder, using the default empty pane view + /// - Parameters: + /// - controller: The BonsplitController managing the tab state + /// - content: A ViewBuilder closure that provides content for each tab. Receives the tab and pane ID. + public init( + controller: BonsplitController, + @ViewBuilder content: @escaping (Tab, PaneID) -> Content + ) { + self.controller = controller + self.contentBuilder = content + self.emptyPaneBuilder = { _ in DefaultEmptyPaneView() } + } +} + +/// Default view shown when a pane has no tabs +public struct DefaultEmptyPaneView: View { + public init() {} + + public var body: some View { + VStack(spacing: 16) { + Image(systemName: "doc.text") + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + + Text("No Open Tabs") + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift b/vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift new file mode 100644 index 00000000000..8c0ca308325 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift @@ -0,0 +1,91 @@ +#if DEBUG +import Foundation + +/// Unified ring-buffer event log for key, mouse, focus, and split events. +/// Writes every entry to a debug log path so `tail -f` works in real time. +public final class DebugEventLog: @unchecked Sendable { + public static let shared = DebugEventLog() + + private var entries: [String] = [] + private let capacity = 500 + private let queue = DispatchQueue(label: "cmux.debug-event-log") + private static let logPath = resolveLogPath() + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f + }() + + private static func sanitizePathToken(_ raw: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) + let unicode = raw.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" } + let sanitized = String(unicode).trimmingCharacters(in: CharacterSet(charactersIn: "-.")) + return sanitized.isEmpty ? "debug" : sanitized + } + + private static func resolveLogPath() -> String { + let env = ProcessInfo.processInfo.environment + + if let explicit = env["CMUX_DEBUG_LOG"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !explicit.isEmpty { + return explicit + } + + if let tag = env["CMUX_TAG"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !tag.isEmpty { + return "/tmp/cmux-debug-\(sanitizePathToken(tag)).log" + } + + if let socketPath = env["CMUX_SOCKET_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !socketPath.isEmpty { + let socketBase = URL(fileURLWithPath: socketPath).deletingPathExtension().lastPathComponent + if socketBase.hasPrefix("cmux-debug-") { + return "/tmp/\(socketBase).log" + } + } + + if let bundleId = Bundle.main.bundleIdentifier, + bundleId != "com.cmuxterm.app.debug" { + return "/tmp/cmux-debug-\(sanitizePathToken(bundleId)).log" + } + + return "/tmp/cmux-debug.log" + } + + public func log(_ msg: String) { + let ts = Self.formatter.string(from: Date()) + let entry = "\(ts) \(msg)" + queue.async { + if self.entries.count >= self.capacity { + self.entries.removeFirst() + } + self.entries.append(entry) + // Append to file for real-time tail -f + let line = entry + "\n" + if let data = line.data(using: .utf8) { + if let handle = FileHandle(forWritingAtPath: Self.logPath) { + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } else { + FileManager.default.createFile(atPath: Self.logPath, contents: data) + } + } + } + } + + /// Write all buffered entries to the log file (full dump, replacing contents). + public func dump() { + queue.async { + let content = self.entries.joined(separator: "\n") + "\n" + try? content.write(toFile: Self.logPath, atomically: true, encoding: .utf8) + } + } +} + +/// Convenience free function. Logs the message and appends to the configured debug log path. +public func dlog(_ msg: String) { + DebugEventLog.shared.log(msg) +} +#endif diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/SafeTooltip.swift b/vendor/bonsplit/Sources/Bonsplit/Public/SafeTooltip.swift new file mode 100644 index 00000000000..68d466227b1 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/SafeTooltip.swift @@ -0,0 +1,136 @@ +import AppKit +import SwiftUI + +private struct SafeTooltipModifier: ViewModifier { + let text: String? + + func body(content: Content) -> some View { + content.background { + SafeTooltipViewRepresentable(text: text) + .allowsHitTesting(false) + } + } +} + +private struct SafeTooltipViewRepresentable: NSViewRepresentable { + let text: String? + + func makeNSView(context: Context) -> SafeTooltipView { + let view = SafeTooltipView() + view.updateTooltip(text) + return view + } + + func updateNSView(_ nsView: SafeTooltipView, context: Context) { + nsView.updateTooltip(text) + } + + static func dismantleNSView(_ nsView: SafeTooltipView, coordinator: ()) { + nsView.invalidateTooltip() + } +} + +private final class SafeTooltipView: NSView { + private var tooltipTag: NSView.ToolTipTag? + private var registeredBounds: NSRect = .zero + private var registeredText: String? + private var tooltipText: String? + + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override func layout() { + super.layout() + refreshTooltipRegistration() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + refreshTooltipRegistration() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + invalidateTooltip() + } else { + refreshTooltipRegistration() + } + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + if superview == nil { + invalidateTooltip() + } else { + refreshTooltipRegistration() + } + } + + func updateTooltip(_ text: String?) { + let normalized = text? + .trimmingCharacters(in: .whitespacesAndNewlines) + tooltipText = normalized?.isEmpty == false ? normalized : nil + refreshTooltipRegistration() + } + + func invalidateTooltip() { + if let tooltipTag { + removeToolTip(tooltipTag) + self.tooltipTag = nil + } + registeredBounds = .zero + registeredText = nil + } + + private func refreshTooltipRegistration() { + guard let tooltipText, + window != nil, + superview != nil else { + invalidateTooltip() + return + } + + let nextBounds = bounds.standardized.integral + guard nextBounds.width > 0, nextBounds.height > 0 else { + invalidateTooltip() + return + } + + if tooltipTag != nil, + nextBounds == registeredBounds, + tooltipText == registeredText { + return + } + + invalidateTooltip() + tooltipTag = addToolTip(nextBounds, owner: self, userData: nil) + registeredBounds = nextBounds + registeredText = tooltipText + } + + @objc + func view( + _ view: NSView, + stringForToolTip tag: NSView.ToolTipTag, + point: NSPoint, + userData data: UnsafeMutableRawPointer? + ) -> String { + tooltipText ?? "" + } + + deinit { + invalidateTooltip() + } +} + +public extension View { + /// Uses an AppKit-backed tooltip host that explicitly unregisters its tooltip + /// before the view is detached or deallocated. + func safeHelp(_ text: String?) -> some View { + modifier(SafeTooltipModifier(text: text)) + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/Types/LayoutSnapshot.swift b/vendor/bonsplit/Sources/Bonsplit/Public/Types/LayoutSnapshot.swift new file mode 100644 index 00000000000..5027ea850ba --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/Types/LayoutSnapshot.swift @@ -0,0 +1,146 @@ +import Foundation + +// MARK: - Pixel Coordinates + +/// Pixel rectangle for external consumption +public struct PixelRect: Codable, Sendable, Equatable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + public init(from cgRect: CGRect) { + self.x = Double(cgRect.origin.x) + self.y = Double(cgRect.origin.y) + self.width = Double(cgRect.size.width) + self.height = Double(cgRect.size.height) + } +} + +// MARK: - Pane Geometry + +/// Geometry for a single pane +public struct PaneGeometry: Codable, Sendable, Equatable { + public let paneId: String + public let frame: PixelRect + public let selectedTabId: String? + public let tabIds: [String] + + public init(paneId: String, frame: PixelRect, selectedTabId: String?, tabIds: [String]) { + self.paneId = paneId + self.frame = frame + self.selectedTabId = selectedTabId + self.tabIds = tabIds + } +} + +// MARK: - Layout Snapshot + +/// Full tree snapshot with pixel coordinates +public struct LayoutSnapshot: Codable, Sendable, Equatable { + public let containerFrame: PixelRect + public let panes: [PaneGeometry] + public let focusedPaneId: String? + public let timestamp: TimeInterval + + public init(containerFrame: PixelRect, panes: [PaneGeometry], focusedPaneId: String?, timestamp: TimeInterval) { + self.containerFrame = containerFrame + self.panes = panes + self.focusedPaneId = focusedPaneId + self.timestamp = timestamp + } +} + +// MARK: - External Tree Representation + +/// External representation of a tab +public struct ExternalTab: Codable, Sendable, Equatable { + public let id: String + public let title: String + + public init(id: String, title: String) { + self.id = id + self.title = title + } +} + +/// External representation of a pane node +public struct ExternalPaneNode: Codable, Sendable, Equatable { + public let id: String + public let frame: PixelRect + public let tabs: [ExternalTab] + public let selectedTabId: String? + + public init(id: String, frame: PixelRect, tabs: [ExternalTab], selectedTabId: String?) { + self.id = id + self.frame = frame + self.tabs = tabs + self.selectedTabId = selectedTabId + } +} + +/// External representation of a split node +public struct ExternalSplitNode: Codable, Sendable, Equatable { + public let id: String + public let orientation: String // "horizontal" or "vertical" + public let dividerPosition: Double // 0.0-1.0 + public let first: ExternalTreeNode + public let second: ExternalTreeNode + + public init(id: String, orientation: String, dividerPosition: Double, first: ExternalTreeNode, second: ExternalTreeNode) { + self.id = id + self.orientation = orientation + self.dividerPosition = dividerPosition + self.first = first + self.second = second + } +} + +/// External representation of the split tree +public indirect enum ExternalTreeNode: Codable, Sendable, Equatable { + case pane(ExternalPaneNode) + case split(ExternalSplitNode) + + // Custom coding keys for enum representation + private enum CodingKeys: String, CodingKey { + case type + case pane + case split + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "pane": + let pane = try container.decode(ExternalPaneNode.self, forKey: .pane) + self = .pane(pane) + case "split": + let split = try container.decode(ExternalSplitNode.self, forKey: .split) + self = .split(split) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .pane(let paneNode): + try container.encode("pane", forKey: .type) + try container.encode(paneNode, forKey: .pane) + case .split(let splitNode): + try container.encode("split", forKey: .type) + try container.encode(splitNode, forKey: .split) + } + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/Types/NavigationDirection.swift b/vendor/bonsplit/Sources/Bonsplit/Public/Types/NavigationDirection.swift new file mode 100644 index 00000000000..692033cd915 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/Types/NavigationDirection.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Navigation directions for keyboard navigation between panes +public enum NavigationDirection: Sendable { + case left + case right + case up + case down +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/Types/PaneID.swift b/vendor/bonsplit/Sources/Bonsplit/Public/Types/PaneID.swift new file mode 100644 index 00000000000..369492c9dac --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/Types/PaneID.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Opaque identifier for panes +public struct PaneID: Hashable, Codable, Sendable, CustomStringConvertible { + public let id: UUID + + public init() { + self.id = UUID() + } + + public init(id: UUID) { + self.id = id + } + + public var description: String { + id.uuidString + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/Types/SplitOrientation.swift b/vendor/bonsplit/Sources/Bonsplit/Public/Types/SplitOrientation.swift new file mode 100644 index 00000000000..a15c965f07f --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/Types/SplitOrientation.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Orientation for splitting panes +public enum SplitOrientation: String, Codable, Sendable { + /// Side-by-side split (left | right) + case horizontal + /// Stacked split (top / bottom) + case vertical +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/Types/Tab.swift b/vendor/bonsplit/Sources/Bonsplit/Public/Types/Tab.swift new file mode 100644 index 00000000000..70fadf40f9f --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/Types/Tab.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Represents a tab's metadata (read-only snapshot for library consumers) +public struct Tab: Identifiable, Hashable, Sendable { + public let id: TabID + public let title: String + public let hasCustomTitle: Bool + public let icon: String? + /// Optional image data (PNG recommended) for the tab icon. When present, this takes precedence over `icon`. + public let iconImageData: Data? + /// Consumer-defined tab kind identifier (for example, "terminal" or "browser"). + public let kind: String? + public let isDirty: Bool + /// Whether the tab should show an "unread/activity" badge (library consumer-defined meaning). + public let showsNotificationBadge: Bool + /// Whether the tab should show an activity/loading indicator (e.g. spinning icon). + public let isLoading: Bool + /// Whether the tab is pinned in its pane. + public let isPinned: Bool + + public init( + id: TabID = TabID(), + title: String, + hasCustomTitle: Bool = false, + icon: String? = nil, + iconImageData: Data? = nil, + kind: String? = nil, + isDirty: Bool = false, + showsNotificationBadge: Bool = false, + isLoading: Bool = false, + isPinned: Bool = false + ) { + self.id = id + self.title = title + self.hasCustomTitle = hasCustomTitle + self.icon = icon + self.iconImageData = iconImageData + self.kind = kind + self.isDirty = isDirty + self.showsNotificationBadge = showsNotificationBadge + self.isLoading = isLoading + self.isPinned = isPinned + } + + internal init(from tabItem: TabItem) { + self.id = TabID(id: tabItem.id) + self.title = tabItem.title + self.hasCustomTitle = tabItem.hasCustomTitle + self.icon = tabItem.icon + self.iconImageData = tabItem.iconImageData + self.kind = tabItem.kind + self.isDirty = tabItem.isDirty + self.showsNotificationBadge = tabItem.showsNotificationBadge + self.isLoading = tabItem.isLoading + self.isPinned = tabItem.isPinned + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/Types/TabContextAction.swift b/vendor/bonsplit/Sources/Bonsplit/Public/Types/TabContextAction.swift new file mode 100644 index 00000000000..47ec09d3275 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/Types/TabContextAction.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Context menu actions that can be triggered from a tab item. +public enum TabContextAction: String, CaseIterable, Sendable { + case rename + case clearName + case closeToLeft + case closeToRight + case closeOthers + case move + case moveToLeftPane + case moveToRightPane + case newTerminalToRight + case newBrowserToRight + case reload + case duplicate + case togglePin + case markAsRead + case markAsUnread + case toggleZoom +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/Types/TabID.swift b/vendor/bonsplit/Sources/Bonsplit/Public/Types/TabID.swift new file mode 100644 index 00000000000..2308c43bd9a --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Public/Types/TabID.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Opaque identifier for tabs +public struct TabID: Hashable, Codable, Sendable { + internal let id: UUID + + public init() { + self.id = UUID() + } + + public init(uuid: UUID) { + self.id = uuid + } + + public var uuid: UUID { + id + } + + internal init(id: UUID) { + self.id = id + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings b/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000000..89fcf28af5e --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings @@ -0,0 +1,2 @@ +"command.moveTabToLeftPane.title" = "Move to Left Pane"; +"command.moveTabToRightPane.title" = "Move to Right Pane"; diff --git a/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings b/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings new file mode 100644 index 00000000000..d272f84f456 --- /dev/null +++ b/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings @@ -0,0 +1,2 @@ +"command.moveTabToLeftPane.title" = "左のペインへ移動"; +"command.moveTabToRightPane.title" = "右のペインへ移動"; diff --git a/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift b/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift new file mode 100644 index 00000000000..6c7d9792a22 --- /dev/null +++ b/vendor/bonsplit/Tests/BonsplitTests/BonsplitTests.swift @@ -0,0 +1,1053 @@ +import XCTest +@testable import Bonsplit +import AppKit +import SwiftUI + +final class BonsplitTests: XCTestCase { + @MainActor + private final class LayoutProbeView: NSView { + private(set) var sizeChangeCount = 0 + private(set) var originChangeCount = 0 + + override func setFrameSize(_ newSize: NSSize) { + if frame.size != newSize { + sizeChangeCount += 1 + } + super.setFrameSize(newSize) + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + if frame.origin != newOrigin { + originChangeCount += 1 + } + super.setFrameOrigin(newOrigin) + } + } + + @MainActor + private struct LayoutProbeRepresentable: NSViewRepresentable { + let probeView: LayoutProbeView + + func makeNSView(context: Context) -> LayoutProbeView { + probeView + } + + func updateNSView(_ nsView: LayoutProbeView, context: Context) {} + } + + @MainActor + private final class DropZoneModel: ObservableObject { + @Published var zone: DropZone? + } + + @MainActor + private struct PaneDropInteractionHarness: View { + @ObservedObject var model: DropZoneModel + let probeView: LayoutProbeView + + var body: some View { + PaneDropInteractionContainer(activeDropZone: model.zone) { + LayoutProbeRepresentable(probeView: probeView) + } dropLayer: { _ in + Color.clear + } + } + } + + private final class TabContextActionDelegateSpy: BonsplitDelegate { + var action: TabContextAction? + var tabId: TabID? + var paneId: PaneID? + + func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { + self.action = action + self.tabId = tab.id + self.paneId = pane + } + } + + private final class NewTabRequestDelegateSpy: BonsplitDelegate { + var requestedKind: String? + var requestedPaneId: PaneID? + + func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) { + requestedKind = kind + requestedPaneId = pane + } + } + + @MainActor + func testControllerCreation() { + let controller = BonsplitController() + XCTAssertNotNil(controller.focusedPaneId) + } + + @MainActor + func testTabCreation() { + let controller = BonsplitController() + let tabId = controller.createTab(title: "Test Tab", icon: "doc") + XCTAssertNotNil(tabId) + } + + @MainActor + func testTabRetrieval() { + let controller = BonsplitController() + let tabId = controller.createTab(title: "Test Tab", icon: "doc")! + let tab = controller.tab(tabId) + XCTAssertEqual(tab?.title, "Test Tab") + XCTAssertEqual(tab?.icon, "doc") + } + + @MainActor + func testTabUpdate() { + let controller = BonsplitController() + let tabId = controller.createTab(title: "Original", icon: "doc")! + + controller.updateTab(tabId, title: "Updated", isDirty: true) + + let tab = controller.tab(tabId) + XCTAssertEqual(tab?.title, "Updated") + XCTAssertEqual(tab?.isDirty, true) + } + + @MainActor + func testTabClose() { + let controller = BonsplitController() + let tabId = controller.createTab(title: "Test Tab", icon: "doc")! + + let closed = controller.closeTab(tabId) + + XCTAssertTrue(closed) + XCTAssertNil(controller.tab(tabId)) + } + + @MainActor + func testCloseSelectedTabKeepsIndexStableWhenPossible() { + do { + let config = BonsplitConfiguration(newTabPosition: .end) + let controller = BonsplitController(configuration: config) + + let tab0 = controller.createTab(title: "0")! + let tab1 = controller.createTab(title: "1")! + let tab2 = controller.createTab(title: "2")! + + let pane = controller.focusedPaneId! + + controller.selectTab(tab1) + XCTAssertEqual(controller.selectedTab(inPane: pane)?.id, tab1) + + _ = controller.closeTab(tab1) + + // Order is [0,1,2] and 1 was selected; after close we should select 2 (same index). + XCTAssertEqual(controller.selectedTab(inPane: pane)?.id, tab2) + XCTAssertNotNil(controller.tab(tab0)) + } + + do { + let config = BonsplitConfiguration(newTabPosition: .end) + let controller = BonsplitController(configuration: config) + + let tab0 = controller.createTab(title: "0")! + let tab1 = controller.createTab(title: "1")! + let tab2 = controller.createTab(title: "2")! + + let pane = controller.focusedPaneId! + + controller.selectTab(tab2) + XCTAssertEqual(controller.selectedTab(inPane: pane)?.id, tab2) + + _ = controller.closeTab(tab2) + + // Closing last should select previous. + XCTAssertEqual(controller.selectedTab(inPane: pane)?.id, tab1) + XCTAssertNotNil(controller.tab(tab0)) + } + } + + @MainActor + func testConfiguration() { + let config = BonsplitConfiguration( + allowSplits: false, + allowCloseTabs: true + ) + let controller = BonsplitController(configuration: config) + + XCTAssertFalse(controller.configuration.allowSplits) + XCTAssertTrue(controller.configuration.allowCloseTabs) + } + + func testDefaultSplitButtonTooltips() { + let defaults = BonsplitConfiguration.SplitButtonTooltips.default + XCTAssertEqual(defaults.newTerminal, "New Terminal") + XCTAssertEqual(defaults.newBrowser, "New Browser") + XCTAssertEqual(defaults.splitRight, "Split Right") + XCTAssertEqual(defaults.splitDown, "Split Down") + } + + @MainActor + func testConfigurationAcceptsCustomSplitButtonTooltips() { + let customTooltips = BonsplitConfiguration.SplitButtonTooltips( + newTerminal: "Terminal (⌘T)", + newBrowser: "Browser (⌘⇧L)", + splitRight: "Split Right (⌘D)", + splitDown: "Split Down (⌘⇧D)" + ) + let config = BonsplitConfiguration( + appearance: .init( + splitButtonTooltips: customTooltips + ) + ) + let controller = BonsplitController(configuration: config) + + XCTAssertEqual(controller.configuration.appearance.splitButtonTooltips, customTooltips) + } + + func testChromeBackgroundHexOverrideParsesForPaneBackground() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#FDF6E3") + ) + let color = TabBarColors.nsColorPaneBackground(for: appearance).usingColorSpace(.sRGB)! + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + XCTAssertEqual(Int(round(red * 255)), 253) + XCTAssertEqual(Int(round(green * 255)), 246) + XCTAssertEqual(Int(round(blue * 255)), 227) + XCTAssertEqual(Int(round(alpha * 255)), 255) + } + + func testChromeBorderHexOverrideParsesForSeparatorColor() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#272822", borderHex: "#112233") + ) + let color = TabBarColors.nsColorSeparator(for: appearance).usingColorSpace(.sRGB)! + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + XCTAssertEqual(Int(round(red * 255)), 17) + XCTAssertEqual(Int(round(green * 255)), 34) + XCTAssertEqual(Int(round(blue * 255)), 51) + XCTAssertEqual(Int(round(alpha * 255)), 255) + } + + func testInvalidChromeBackgroundHexFallsBackToPaneDefaultColor() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#ZZZZZZ") + ) + let resolved = TabBarColors.nsColorPaneBackground(for: appearance).usingColorSpace(.sRGB)! + let fallback = NSColor.textBackgroundColor.usingColorSpace(.sRGB)! + + var rr: CGFloat = 0 + var rg: CGFloat = 0 + var rb: CGFloat = 0 + var ra: CGFloat = 0 + resolved.getRed(&rr, green: &rg, blue: &rb, alpha: &ra) + + var fr: CGFloat = 0 + var fg: CGFloat = 0 + var fb: CGFloat = 0 + var fa: CGFloat = 0 + fallback.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + + XCTAssertEqual(rr, fr, accuracy: 0.0001) + XCTAssertEqual(rg, fg, accuracy: 0.0001) + XCTAssertEqual(rb, fb, accuracy: 0.0001) + XCTAssertEqual(ra, fa, accuracy: 0.0001) + } + + func testPartiallyInvalidChromeBackgroundHexFallsBackToPaneDefaultColor() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#FF000G") + ) + let resolved = TabBarColors.nsColorPaneBackground(for: appearance).usingColorSpace(.sRGB)! + let fallback = NSColor.textBackgroundColor.usingColorSpace(.sRGB)! + + var rr: CGFloat = 0 + var rg: CGFloat = 0 + var rb: CGFloat = 0 + var ra: CGFloat = 0 + resolved.getRed(&rr, green: &rg, blue: &rb, alpha: &ra) + + var fr: CGFloat = 0 + var fg: CGFloat = 0 + var fb: CGFloat = 0 + var fa: CGFloat = 0 + fallback.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + + XCTAssertEqual(rr, fr, accuracy: 0.0001) + XCTAssertEqual(rg, fg, accuracy: 0.0001) + XCTAssertEqual(rb, fb, accuracy: 0.0001) + XCTAssertEqual(ra, fa, accuracy: 0.0001) + } + + func testInactiveTextUsesLightForegroundOnDarkCustomChromeBackground() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#272822") + ) + let color = TabBarColors.nsColorInactiveText(for: appearance).usingColorSpace(.sRGB)! + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + XCTAssertGreaterThan(red, 0.5) + XCTAssertGreaterThan(green, 0.5) + XCTAssertGreaterThan(blue, 0.5) + XCTAssertGreaterThan(alpha, 0.6) + } + + func testSplitActionPressedStateUsesHigherContrast() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#272822") + ) + + let idleIcon = TabBarColors.nsColorSplitActionIcon(for: appearance, isPressed: false).usingColorSpace(.sRGB)! + let pressedIcon = TabBarColors.nsColorSplitActionIcon(for: appearance, isPressed: true).usingColorSpace(.sRGB)! + + var idleAlpha: CGFloat = 0 + idleIcon.getRed(nil, green: nil, blue: nil, alpha: &idleAlpha) + var pressedAlpha: CGFloat = 0 + pressedIcon.getRed(nil, green: nil, blue: nil, alpha: &pressedAlpha) + + XCTAssertGreaterThan(pressedAlpha, idleAlpha) + } + + @MainActor + func testMoveTabNoopAfterItself() { + let t0 = TabItem(title: "0") + let t1 = TabItem(title: "1") + let pane = PaneState(tabs: [t0, t1], selectedTabId: t1.id) + + // Dragging the last tab to the right corresponds to moving it to `tabs.count`, + // which should be treated as a no-op. + pane.moveTab(from: 1, to: 2) + XCTAssertEqual(pane.tabs.map(\.id), [t0.id, t1.id]) + XCTAssertEqual(pane.selectedTabId, t1.id) + + // Still allow real moves. + pane.moveTab(from: 0, to: 2) + XCTAssertEqual(pane.tabs.map(\.id), [t1.id, t0.id]) + XCTAssertEqual(pane.selectedTabId, t1.id) + } + + @MainActor + func testPinnedTabInsertionsStayAheadOfUnpinnedTabs() { + let unpinnedA = TabItem(title: "A", isPinned: false) + let unpinnedB = TabItem(title: "B", isPinned: false) + let pinned = TabItem(title: "Pinned", isPinned: true) + let pane = PaneState(tabs: [unpinnedA, unpinnedB], selectedTabId: unpinnedA.id) + + pane.insertTab(pinned, at: 2) + + XCTAssertEqual(pane.tabs.map(\.isPinned), [true, false, false]) + XCTAssertEqual(pane.tabs.first?.id, pinned.id) + } + + @MainActor + func testMovingUnpinnedTabCannotCrossPinnedBoundary() { + let pinnedA = TabItem(title: "Pinned A", isPinned: true) + let pinnedB = TabItem(title: "Pinned B", isPinned: true) + let unpinnedA = TabItem(title: "A", isPinned: false) + let unpinnedB = TabItem(title: "B", isPinned: false) + let pane = PaneState( + tabs: [pinnedA, pinnedB, unpinnedA, unpinnedB], + selectedTabId: unpinnedB.id + ) + + // Attempt to move an unpinned tab ahead of pinned tabs; move should clamp to + // the first unpinned position. + pane.moveTab(from: 3, to: 0) + + XCTAssertEqual(pane.tabs.map(\.id), [pinnedA.id, pinnedB.id, unpinnedB.id, unpinnedA.id]) + XCTAssertEqual(pane.tabs.prefix(2).allSatisfy(\.isPinned), true) + XCTAssertEqual(pane.tabs.suffix(2).allSatisfy { !$0.isPinned }, true) + } + + @MainActor + func testCreateTabStoresKindAndPinnedState() { + let controller = BonsplitController() + let tabId = controller.createTab( + title: "Browser", + icon: "globe", + kind: "browser", + isPinned: true + )! + + let tab = controller.tab(tabId) + XCTAssertEqual(tab?.kind, "browser") + XCTAssertEqual(tab?.isPinned, true) + } + + @MainActor + func testCreateAndUpdateTabCustomTitleFlag() { + let controller = BonsplitController() + let tabId = controller.createTab( + title: "Infra", + hasCustomTitle: true + )! + + XCTAssertEqual(controller.tab(tabId)?.hasCustomTitle, true) + + controller.updateTab(tabId, hasCustomTitle: false) + XCTAssertEqual(controller.tab(tabId)?.hasCustomTitle, false) + } + + @MainActor + func testSplitPaneWithOptionalTabPreservesCustomTitleFlag() { + let controller = BonsplitController() + _ = controller.createTab(title: "Base") + let sourcePaneId = controller.focusedPaneId! + let customTab = Bonsplit.Tab(title: "Custom", hasCustomTitle: true) + + guard let newPaneId = controller.splitPane(sourcePaneId, orientation: .horizontal, withTab: customTab) else { + return XCTFail("Expected splitPane to return new pane") + } + let inserted = controller.tabs(inPane: newPaneId).first(where: { $0.id == customTab.id }) + XCTAssertEqual(inserted?.hasCustomTitle, true) + } + + @MainActor + func testSplitPaneWithInsertSidePreservesCustomTitleFlag() { + let controller = BonsplitController() + _ = controller.createTab(title: "Base") + let sourcePaneId = controller.focusedPaneId! + let customTab = Bonsplit.Tab(title: "Custom", hasCustomTitle: true) + + guard let newPaneId = controller.splitPane( + sourcePaneId, + orientation: .vertical, + withTab: customTab, + insertFirst: true + ) else { + return XCTFail("Expected splitPane(insertFirst:) to return new pane") + } + let inserted = controller.tabs(inPane: newPaneId).first(where: { $0.id == customTab.id }) + XCTAssertEqual(inserted?.hasCustomTitle, true) + } + + @MainActor + func testTogglePaneZoomTracksState() { + let controller = BonsplitController() + guard let originalPane = controller.focusedPaneId else { + return XCTFail("Expected focused pane") + } + + // Single-pane layouts cannot be zoomed. + XCTAssertFalse(controller.togglePaneZoom(inPane: originalPane)) + XCTAssertNil(controller.zoomedPaneId) + + guard controller.splitPane(originalPane, orientation: .horizontal) != nil else { + return XCTFail("Expected splitPane to create a new pane") + } + + XCTAssertTrue(controller.togglePaneZoom(inPane: originalPane)) + XCTAssertEqual(controller.zoomedPaneId, originalPane) + XCTAssertTrue(controller.isSplitZoomed) + + XCTAssertTrue(controller.togglePaneZoom(inPane: originalPane)) + XCTAssertNil(controller.zoomedPaneId) + XCTAssertFalse(controller.isSplitZoomed) + } + + @MainActor + func testSplitClearsExistingPaneZoom() { + let controller = BonsplitController() + guard let originalPane = controller.focusedPaneId else { + return XCTFail("Expected focused pane") + } + + guard let secondPane = controller.splitPane(originalPane, orientation: .horizontal) else { + return XCTFail("Expected splitPane to create a new pane") + } + + XCTAssertTrue(controller.togglePaneZoom(inPane: secondPane)) + XCTAssertEqual(controller.zoomedPaneId, secondPane) + + _ = controller.splitPane(secondPane, orientation: .vertical) + XCTAssertNil(controller.zoomedPaneId, "Splitting should reset zoom state") + } + + @MainActor + func testRequestTabContextActionForwardsToDelegate() { + let controller = BonsplitController() + let pane = controller.focusedPaneId! + let tabId = controller.createTab(title: "Test", kind: "browser")! + let spy = TabContextActionDelegateSpy() + controller.delegate = spy + + controller.requestTabContextAction(.reload, for: tabId, inPane: pane) + + XCTAssertEqual(spy.action, .reload) + XCTAssertEqual(spy.tabId, tabId) + XCTAssertEqual(spy.paneId, pane) + } + + @MainActor + func testRequestTabContextActionForwardsMarkAsReadToDelegate() { + let controller = BonsplitController() + let pane = controller.focusedPaneId! + let tabId = controller.createTab(title: "Test", kind: "terminal")! + let spy = TabContextActionDelegateSpy() + controller.delegate = spy + + controller.requestTabContextAction(.markAsRead, for: tabId, inPane: pane) + + XCTAssertEqual(spy.action, .markAsRead) + XCTAssertEqual(spy.tabId, tabId) + XCTAssertEqual(spy.paneId, pane) + } + + @MainActor + func testDoubleClickingEmptyTrailingTabBarSpaceRequestsNewTerminalTab() { + let appearance = BonsplitConfiguration.Appearance(showSplitButtons: false) + let configuration = BonsplitConfiguration(appearance: appearance) + let controller = BonsplitController(configuration: configuration) + let pane = controller.internalController.rootNode.allPanes.first! + let spy = NewTabRequestDelegateSpy() + controller.delegate = spy + + let hostingView = NSHostingView( + rootView: TabBarView(pane: pane, isFocused: true, showSplitButtons: false) + .environment(controller) + .environment(controller.internalController) + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 60), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + hostingView.frame = contentView.bounds + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + + let clickPoint = NSPoint(x: hostingView.bounds.maxX - 12, y: hostingView.bounds.midY) + guard let event = try? makeLeftMouseDownEvent(in: hostingView, at: clickPoint, clickCount: 2) else { + XCTFail("Expected mouse event") + return + } + NSApp.sendEvent(event) + + XCTAssertEqual(spy.requestedKind, "terminal") + XCTAssertEqual(spy.requestedPaneId, pane.id) + } + + func testIconSaturationKeepsRasterFaviconInColorWhenInactive() { + XCTAssertEqual( + TabItemStyling.iconSaturation(hasRasterIcon: true, tabSaturation: 0.0), + 1.0 + ) + } + + func testIconSaturationStillDesaturatesSymbolIconsWhenInactive() { + XCTAssertEqual( + TabItemStyling.iconSaturation(hasRasterIcon: false, tabSaturation: 0.0), + 0.0 + ) + } + + func testResolvedFaviconImageUsesIncomingDataWhenDecodable() { + let existing = NSImage(size: NSSize(width: 12, height: 12)) + let incoming = NSImage(size: NSSize(width: 16, height: 16)) + incoming.lockFocus() + NSColor.systemBlue.setFill() + NSBezierPath(rect: NSRect(x: 0, y: 0, width: 16, height: 16)).fill() + incoming.unlockFocus() + let data = incoming.tiffRepresentation + + let resolved = TabItemStyling.resolvedFaviconImage(existing: existing, incomingData: data) + XCTAssertNotNil(resolved) + XCTAssertFalse(resolved === existing) + } + + func testResolvedFaviconImageKeepsExistingImageWhenIncomingDataIsInvalid() { + let existing = NSImage(size: NSSize(width: 16, height: 16)) + let invalidData = Data([0x00, 0x11, 0x22, 0x33]) + + let resolved = TabItemStyling.resolvedFaviconImage(existing: existing, incomingData: invalidData) + XCTAssertTrue(resolved === existing) + } + + func testResolvedFaviconImageClearsWhenIncomingDataIsNil() { + let existing = NSImage(size: NSSize(width: 16, height: 16)) + let resolved = TabItemStyling.resolvedFaviconImage(existing: existing, incomingData: nil) + XCTAssertNil(resolved) + } + + func testTabControlShortcutHintPolicyMatchesConfiguredModifiers() { + withShortcutHintDefaultsSuite { defaults in + defaults.set(true, forKey: TabControlShortcutHintPolicy.showHintsOnCommandHoldKey) + + XCTAssertNotNil(TabControlShortcutHintPolicy.hintModifier(for: [.control], defaults: defaults)) + XCTAssertNil(TabControlShortcutHintPolicy.hintModifier(for: [], defaults: defaults)) + XCTAssertNil(TabControlShortcutHintPolicy.hintModifier(for: [.control, .shift], defaults: defaults)) + XCTAssertNil(TabControlShortcutHintPolicy.hintModifier(for: [.command], defaults: defaults)) + + defaults.set( + shortcutData( + key: "1", + command: true, + shift: false, + option: true, + control: false + ), + forKey: "shortcut.selectSurfaceByNumber" + ) + + let custom = TabControlShortcutHintPolicy.hintModifier(for: [.command, .option], defaults: defaults) + XCTAssertEqual(custom?.symbol, "⌥⌘") + XCTAssertNil(TabControlShortcutHintPolicy.hintModifier(for: [.control], defaults: defaults)) + } + } + + func testTabControlShortcutHintPolicyCanDisableHoldHints() { + withShortcutHintDefaultsSuite { defaults in + defaults.set(false, forKey: TabControlShortcutHintPolicy.showHintsOnCommandHoldKey) + + XCTAssertNil(TabControlShortcutHintPolicy.hintModifier(for: [.control], defaults: defaults)) + XCTAssertNil(TabControlShortcutHintPolicy.hintModifier(for: [.command], defaults: defaults)) + } + } + + func testTabControlShortcutHintPolicyDefaultsToShowingHoldHints() { + withShortcutHintDefaultsSuite { defaults in + defaults.removeObject(forKey: TabControlShortcutHintPolicy.showHintsOnCommandHoldKey) + + XCTAssertEqual(TabControlShortcutHintPolicy.hintModifier(for: [.control], defaults: defaults)?.symbol, "⌃") + XCTAssertNil(TabControlShortcutHintPolicy.hintModifier(for: [.command], defaults: defaults)) + } + } + + func testTabControlShortcutHintsAreScopedToCurrentKeyWindow() { + withShortcutHintDefaultsSuite { defaults in + defaults.set(true, forKey: TabControlShortcutHintPolicy.showHintsOnCommandHoldKey) + + XCTAssertTrue( + TabControlShortcutHintPolicy.shouldShowHints( + for: [.control], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 42, + keyWindowNumber: 42, + defaults: defaults + ) + ) + + XCTAssertFalse( + TabControlShortcutHintPolicy.shouldShowHints( + for: [.control], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 7, + keyWindowNumber: 42, + defaults: defaults + ) + ) + + XCTAssertFalse( + TabControlShortcutHintPolicy.shouldShowHints( + for: [.control], + hostWindowNumber: 42, + hostWindowIsKey: false, + eventWindowNumber: 42, + keyWindowNumber: 42, + defaults: defaults + ) + ) + } + } + + func testTabControlShortcutHintsFallbackToKeyWindowWhenEventWindowMissing() { + withShortcutHintDefaultsSuite { defaults in + defaults.set(true, forKey: TabControlShortcutHintPolicy.showHintsOnCommandHoldKey) + + XCTAssertTrue( + TabControlShortcutHintPolicy.shouldShowHints( + for: [.control], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) + ) + + XCTAssertFalse( + TabControlShortcutHintPolicy.shouldShowHints( + for: [.control], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 7, + defaults: defaults + ) + ) + } + } + + func testSelectedTabNeverShowsHoverBackground() { + XCTAssertFalse( + TabItemStyling.shouldShowHoverBackground(isHovered: true, isSelected: true) + ) + XCTAssertTrue( + TabItemStyling.shouldShowHoverBackground(isHovered: true, isSelected: false) + ) + XCTAssertFalse( + TabItemStyling.shouldShowHoverBackground(isHovered: false, isSelected: false) + ) + } + + func testTabBarSeparatorSegmentsClampGapIntoBounds() { + var segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: -20...40) + XCTAssertEqual(segments.left, 0, accuracy: 0.0001) + XCTAssertEqual(segments.right, 60, accuracy: 0.0001) + + segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: 25...120) + XCTAssertEqual(segments.left, 25, accuracy: 0.0001) + XCTAssertEqual(segments.right, 0, accuracy: 0.0001) + + segments = TabBarStyling.separatorSegments(totalWidth: 100, gap: nil) + XCTAssertEqual(segments.left, 100, accuracy: 0.0001) + XCTAssertEqual(segments.right, 0, accuracy: 0.0001) + } + + @MainActor + func testPaneDropOverlayDoesNotResizeHostedContentDuringHover() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let model = DropZoneModel() + let probeView = LayoutProbeView(frame: .zero) + let hostingView = NSHostingView( + rootView: PaneDropInteractionHarness( + model: model, + probeView: probeView + ) + ) + hostingView.frame = contentView.bounds + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + + let initialFrame = probeView.frame + let initialSizeChanges = probeView.sizeChangeCount + let initialOriginChanges = probeView.originChangeCount + + model.zone = .left + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + + XCTAssertEqual(probeView.frame, initialFrame) + XCTAssertEqual( + probeView.sizeChangeCount, + initialSizeChanges, + "Drag-hover overlays must not resize the hosted pane content" + ) + XCTAssertEqual( + probeView.originChangeCount, + initialOriginChanges, + "Drag-hover overlays must not move the hosted pane content" + ) + + model.zone = .bottom + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + + XCTAssertEqual(probeView.frame, initialFrame) + XCTAssertEqual( + probeView.sizeChangeCount, + initialSizeChanges, + "Switching hover targets should keep the hosted pane geometry stable" + ) + XCTAssertEqual( + probeView.originChangeCount, + initialOriginChanges, + "Switching hover targets should not reposition the hosted pane content" + ) + } + + @MainActor + func testTranslucentSplitWrappersStayClear() { + let appearance = BonsplitConfiguration.Appearance( + enableAnimations: false, + chromeColors: .init(backgroundHex: "#11223380") + ) + let configuration = BonsplitConfiguration(appearance: appearance) + let controller = BonsplitController(configuration: configuration) + _ = controller.createTab(title: "Base") + guard let sourcePane = controller.focusedPaneId else { + XCTFail("Expected focused pane") + return + } + guard controller.splitPane(sourcePane, orientation: .horizontal) != nil else { + XCTFail("Expected splitPane to create a new pane") + return + } + + let hostingView = NSHostingView( + rootView: BonsplitView(controller: controller) { _, _ in + Color.clear + } emptyPane: { _ in + Color.clear + } + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + hostingView.frame = contentView.bounds + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + + guard let splitView = firstDescendant(ofType: NSSplitView.self, in: hostingView) else { + XCTFail("Expected split view") + return + } + XCTAssertEqual(splitView.arrangedSubviews.count, 2) + + let dividerBackground = splitView.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)) + XCTAssertNotNil(dividerBackground, "Expected split view to be layer-backed") + XCTAssertEqual( + dividerBackground?.alphaComponent ?? 0, + 0, + accuracy: 0.001, + "Split root should stay clear so translucent pane chrome is painted only once" + ) + + for container in splitView.arrangedSubviews { + let background = container.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)) + XCTAssertNotNil(background, "Expected arranged subview to be layer-backed") + XCTAssertEqual( + background?.alphaComponent ?? -1, + 0, + accuracy: 0.001, + "Split-only wrapper containers should stay clear so translucent pane chrome is not composited twice" + ) + } + } + + @MainActor + func testSplitContentAlphaMatchesSinglePane() { + let appearance = BonsplitConfiguration.Appearance( + enableAnimations: false, + chromeColors: .init(backgroundHex: "#11223380") + ) + let expectedAlpha = CGFloat(128.0 / 255.0) + let samplePoint = NSPoint(x: 100, y: 100) + + let singlePaneController = BonsplitController( + configuration: BonsplitConfiguration(appearance: appearance) + ) + _ = singlePaneController.createTab(title: "Base") + + guard let singlePaneAlpha = renderedAlpha( + for: singlePaneController, + samplePoint: samplePoint + ) else { + XCTFail("Expected single-pane rendered alpha") + return + } + XCTAssertEqual( + singlePaneAlpha, + expectedAlpha, + accuracy: 0.03, + "Single-pane content should preserve the configured translucent alpha" + ) + + let splitController = BonsplitController( + configuration: BonsplitConfiguration(appearance: appearance) + ) + _ = splitController.createTab(title: "Base") + guard let sourcePane = splitController.focusedPaneId else { + XCTFail("Expected focused pane") + return + } + guard splitController.splitPane(sourcePane, orientation: .horizontal) != nil else { + XCTFail("Expected splitPane to create a new pane") + return + } + + guard let splitAlpha = renderedAlpha( + for: splitController, + samplePoint: samplePoint + ) else { + XCTFail("Expected split rendered alpha") + return + } + + XCTAssertEqual( + splitAlpha, + singlePaneAlpha, + accuracy: 0.03, + "Split mode should render the same content alpha as single-pane mode" + ) + } + + private func withShortcutHintDefaultsSuite(_ body: (UserDefaults) -> Void) { + let suiteName = "BonsplitShortcutHintPolicyTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create defaults suite") + return + } + + defaults.removePersistentDomain(forName: suiteName) + body(defaults) + defaults.removePersistentDomain(forName: suiteName) + } + + private func shortcutData( + key: String, + command: Bool, + shift: Bool, + option: Bool, + control: Bool + ) -> Data { + let payload: [String: Any] = [ + "key": key, + "command": command, + "shift": shift, + "option": option, + "control": control + ] + return try! JSONSerialization.data(withJSONObject: payload, options: []) + } + + private func firstDescendant(ofType type: T.Type, in root: NSView) -> T? { + if let match = root as? T { + return match + } + for subview in root.subviews { + if let match = firstDescendant(ofType: type, in: subview) { + return match + } + } + return nil + } + + @MainActor + private func renderedAlpha( + for controller: BonsplitController, + samplePoint: NSPoint, + size: NSSize = NSSize(width: 800, height: 600) + ) -> CGFloat? { + let hostingView = NSHostingView( + rootView: BonsplitView(controller: controller) { _, _ in + Color.clear + } emptyPane: { _ in + Color.clear + } + ) + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.isOpaque = false + window.backgroundColor = .clear + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { return nil } + + contentView.wantsLayer = true + contentView.layer?.backgroundColor = NSColor.clear.cgColor + hostingView.frame = contentView.bounds + hostingView.autoresizingMask = [.width, .height] + contentView.addSubview(hostingView) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + contentView.layoutSubtreeIfNeeded() + + return renderedColor(in: hostingView, at: samplePoint)?.alphaComponent + } + + @MainActor + private func renderedColor(in view: NSView, at point: NSPoint) -> NSColor? { + let integralBounds = view.bounds.integral + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: integralBounds) else { return nil } + bitmap.size = integralBounds.size + view.cacheDisplay(in: integralBounds, to: bitmap) + + let x = Int(point.x.rounded()) + let y = Int(point.y.rounded()) + guard x >= 0, + y >= 0, + x < bitmap.pixelsWide, + y < bitmap.pixelsHigh else { return nil } + return bitmap.colorAt(x: x, y: y) + } + + @MainActor + private func makeLeftMouseDownEvent( + in view: NSView, + at point: NSPoint, + clickCount: Int + ) throws -> NSEvent { + guard let window = view.window else { + throw NSError(domain: "BonsplitTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing window"]) + } + let pointInWindow = view.convert(point, to: nil) + guard let event = NSEvent.mouseEvent( + with: .leftMouseDown, + location: pointInWindow, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: clickCount, + pressure: 1 + ) else { + throw NSError(domain: "BonsplitTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to create mouse event"]) + } + return event + } +} From af6193079a5237067ecd28157de279d1b8d207c3 Mon Sep 17 00:00:00 2001 From: lsoengas Date: Fri, 10 Jul 2026 13:11:23 -0300 Subject: [PATCH 2/2] fix: allow dragging the window from the tab bar's empty areas The tab bar's empty-space drag zones only moved the window in minimal presentation mode, and the 114pt gutter reserved for the split-action icons was bare SwiftUI padding with no view behind it, so clicks there were swallowed by the scroll view and did nothing. Un-gate the drag zones so the empty strip drags the window in every presentation mode, and replace the dead trailing padding with a real transparent drag-capture view using the same mechanism as the existing 30pt zone after the last tab. Clicks on tabs, drag-to-reorder, hover tracking, and minimal mode's double-click zoom are unchanged. --- .../Bonsplit/Internal/Views/TabBarView.swift | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift index 8d083c90b82..b4ccce09e2b 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -123,9 +123,19 @@ struct TabBarView: View { // Unified drop zone after the last tab. dropZoneAfterTabs + + // Reserved gutter behind the split-action icon cluster. This used to + // be plain `.padding(.trailing, 114)` — dead space with no NSView, so + // AppKit's hit-test walk fell into the ScrollView's own hosting/clip + // view here instead of reaching the tab bar's window-drag background, + // silently swallowing clicks (#window-drag-areas). A real (still + // fully transparent) drag-capture view makes window drag work in this + // strip the same way it already does in the 30pt zone before it. + if showSplitButtons { + splitButtonsGutterDragZone + } } .padding(.horizontal, TabBarMetrics.barPadding) - .padding(.trailing, showSplitButtons ? 114 : 0) .animation(nil, value: pane.tabs.map(\.id)) .background( GeometryReader { contentGeo in @@ -457,6 +467,19 @@ struct TabBarView: View { } } + // MARK: - Split Buttons Gutter Drag Zone + + /// Fixed-width gutter reserved behind the split-action icon cluster (terminal/browser/ + /// split buttons), matching the 114pt previously consumed by trailing padding. Kept as a + /// real drag-capturing view (not bare padding) so window drag works there. Double-click + /// falls through to the same drag path (no new-tab action) — consistent with the rest of + /// this empty strip in standard presentation mode. + @ViewBuilder + private var splitButtonsGutterDragZone: some View { + TabBarDragZoneView { false } + .frame(width: 114, height: TabBarMetrics.tabHeight) + } + // MARK: - Drop Indicator @ViewBuilder @@ -713,11 +736,15 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { } override func mouseDown(with event: NSEvent) { - guard isMinimalMode, let window else { + guard let window else { super.mouseDown(with: event) return } if event.clickCount >= 2 { + guard isMinimalMode else { + super.mouseDown(with: event) + return + } let action = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?["AppleActionOnDoubleClick"] as? String switch action { case "Minimize": window.miniaturize(nil) @@ -752,7 +779,7 @@ private struct TabBarDragZoneView: NSViewRepresentable { var onDoubleClick: (() -> Bool)? override var mouseDownCanMoveWindow: Bool { - return UserDefaults.standard.string(forKey: "workspacePresentationMode") == "minimal" + return true } override func hitTest(_ point: NSPoint) -> NSView? { @@ -780,14 +807,10 @@ private struct TabBarDragZoneView: NSViewRepresentable { } } - if UserDefaults.standard.string(forKey: "workspacePresentationMode") == "minimal" { - let wasMovable = window.isMovable - window.isMovable = true - window.performDrag(with: event) - window.isMovable = wasMovable - } else { - super.mouseDown(with: event) - } + let wasMovable = window.isMovable + window.isMovable = true + window.performDrag(with: event) + window.isMovable = wasMovable } } }