Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions Sources/Bonsplit/Internal/Views/BonsplitHostingView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import AppKit
import SwiftUI

class BonsplitHostingView<Content: View>: NSHostingView<Content> {
private let zeroSafeAreaLayoutGuide = NSLayoutGuide()

override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero }
override var safeAreaRect: NSRect { bounds }
override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide }
override var mouseDownCanMoveWindow: Bool { false }
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
}

required init(rootView: Content) {
super.init(rootView: rootView)
addLayoutGuide(zeroSafeAreaLayoutGuide)
NSLayoutConstraint.activate([
zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor),
zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor),
zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor),
zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

final class BonsplitHostingController<Content: View>: NSViewController {
private let hostingView: BonsplitHostingView<Content>

var rootView: Content {
get { hostingView.rootView }
set { hostingView.rootView = newValue }
}

init(rootView: Content) {
hostingView = BonsplitHostingView(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func loadView() {
view = hostingView
}
}
60 changes: 56 additions & 4 deletions Sources/Bonsplit/Internal/Views/PaneContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,56 @@ import SwiftUI
import UniformTypeIdentifiers
import AppKit

private final class TabBarInteractionContainerView: NSView {
override var mouseDownCanMoveWindow: Bool { false }
override func isAccessibilityElement() -> Bool { true }
override func accessibilityRole() -> NSAccessibility.Role? { .group }
}

private final class TabBarInteractionHostingView<Content: View>: BonsplitHostingView<Content> {
override func hitTest(_ point: NSPoint) -> NSView? {
guard super.hitTest(point) != nil else { return nil }
return self
}
}

private struct TabBarHostingWrapper<Content: View>: NSViewRepresentable {
let content: Content

func makeCoordinator() -> Coordinator {
Coordinator()
}

func makeNSView(context: Context) -> NSView {
let containerView = TabBarInteractionContainerView()
containerView.setAccessibilityElement(true)
containerView.setAccessibilityIdentifier("paneTabBar")

let hostingView = TabBarInteractionHostingView(rootView: content)
hostingView.translatesAutoresizingMaskIntoConstraints = false
hostingView.setAccessibilityElement(false)
containerView.addSubview(hostingView)

NSLayoutConstraint.activate([
hostingView.topAnchor.constraint(equalTo: containerView.topAnchor),
hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])

context.coordinator.hostingView = hostingView
return containerView
}

func updateNSView(_ nsView: NSView, context: Context) {
context.coordinator.hostingView?.rootView = content
}

final class Coordinator {
var hostingView: TabBarInteractionHostingView<Content>?
}
}

/// Drop zone positions for creating splits
public enum DropZone: Equatable {
case center
Expand Down Expand Up @@ -174,10 +224,12 @@ struct PaneContainerView<Content: View, EmptyContent: View>: View {
var body: some View {
VStack(spacing: 0) {
// Tab bar
TabBarView(
pane: pane,
isFocused: isFocused,
showSplitButtons: showSplitButtons
TabBarHostingWrapper(
content: TabBarView(
pane: pane,
isFocused: isFocused,
showSplitButtons: showSplitButtons
)
)

// Content area with drop zones
Expand Down
22 changes: 8 additions & 14 deletions Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl

// 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()
let firstContainer = PaneDragContainerView()
firstContainer.wantsLayer = true
firstContainer.layer?.backgroundColor = chromeBackgroundColor.cgColor
firstContainer.layer?.masksToBounds = true
Expand All @@ -131,7 +131,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
splitView.addArrangedSubview(firstContainer)
context.coordinator.firstHostingController = firstController

let secondContainer = NSView()
let secondContainer = PaneDragContainerView()
secondContainer.wantsLayer = true
secondContainer.layer?.backgroundColor = chromeBackgroundColor.cgColor
secondContainer.layer?.masksToBounds = true
Expand Down Expand Up @@ -355,14 +355,8 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl

// MARK: - Helpers

private func makeHostingController(for node: SplitNode) -> NSHostingController<AnyView> {
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 = []
}

private func makeHostingController(for node: SplitNode) -> BonsplitHostingController<AnyView> {
let hostingController = BonsplitHostingController(rootView: AnyView(makeView(for: node)))
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
Expand All @@ -378,7 +372,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
return hostingController
}

private func installHostingController(_ hostingController: NSHostingController<AnyView>, into container: NSView) {
private func installHostingController(_ hostingController: BonsplitHostingController<AnyView>, into container: NSView) {
let hostedView = hostingController.view
hostedView.frame = container.bounds
hostedView.autoresizingMask = [.width, .height]
Expand All @@ -391,7 +385,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
in container: NSView,
node: SplitNode,
nodeTypeChanged: Bool,
controller: inout NSHostingController<AnyView>?
controller: inout BonsplitHostingController<AnyView>?
) {
// Historically we recreated the NSHostingController when the child node type changed
// (pane <-> split) to force a full detach/reattach of native AppKit subviews.
Expand Down Expand Up @@ -468,8 +462,8 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
var firstNodeType: SplitNode.NodeType
var secondNodeType: SplitNode.NodeType
/// Retain hosting controllers so SwiftUI content stays alive
var firstHostingController: NSHostingController<AnyView>?
var secondHostingController: NSHostingController<AnyView>?
var firstHostingController: BonsplitHostingController<AnyView>?
var secondHostingController: BonsplitHostingController<AnyView>?

init(
splitState: SplitState,
Expand Down
8 changes: 5 additions & 3 deletions Sources/Bonsplit/Internal/Views/SplitNodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ struct SplitNodeView<Content: View, EmptyContent: View>: View {
}

/// Container NSView for a pane inside SinglePaneWrapper.
class PaneDragContainerView: NSView {}
class PaneDragContainerView: NSView {
override var mouseDownCanMoveWindow: Bool { false }
}

/// Wrapper that uses NSHostingController for proper AppKit layout constraints
struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable {
Expand All @@ -66,7 +68,7 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
showSplitButtons: showSplitButtons,
contentViewLifecycle: contentViewLifecycle
)
let hostingController = NSHostingController(rootView: paneView)
let hostingController = BonsplitHostingController(rootView: paneView)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false

let containerView = PaneDragContainerView()
Expand Down Expand Up @@ -110,6 +112,6 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
}

class Coordinator {
var hostingController: NSHostingController<PaneContainerView<Content, EmptyContent>>?
var hostingController: BonsplitHostingController<PaneContainerView<Content, EmptyContent>>?
}
}
92 changes: 79 additions & 13 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ struct TabContextMenuState {
}
}

private enum TabBarControlsVisibilityMode: String {
case always
case onHover
}

/// Tab bar view with scrollable tabs, drag/drop support, and split buttons
struct TabBarView: View {
@Environment(BonsplitController.self) private var controller
Expand All @@ -68,7 +73,12 @@ struct TabBarView: View {
@State private var contentWidth: CGFloat = 0
@State private var containerWidth: CGFloat = 0
@State private var selectedTabFrameInBar: CGRect?
@State private var isHoveringTabBar = false
@StateObject private var controlKeyMonitor = TabControlShortcutKeyMonitor()
@AppStorage("paneTabBarControlsVisibilityMode")
private var controlsVisibilityModeRawValue = TabBarControlsVisibilityMode.always.rawValue
@AppStorage("workspaceTitlebarVisible")
private var showWorkspaceTitlebar = true

private var canScrollLeft: Bool {
scrollOffset > 1
Expand All @@ -95,6 +105,27 @@ struct TabBarView: View {
isFocused && controlKeyMonitor.isShortcutHintVisible
}

private var controlsVisibilityMode: TabBarControlsVisibilityMode {
TabBarControlsVisibilityMode(rawValue: controlsVisibilityModeRawValue) ?? .always
}

private var shouldShowSplitButtonsNow: Bool {
switch controlsVisibilityMode {
case .always:
return true
case .onHover:
return isHoveringTabBar
}
}

private var shouldUseTabBarDragRegion: Bool {
!showWorkspaceTitlebar
}

private var isTabDragActive: Bool {
splitViewController.draggingTab != nil || splitViewController.activeDragTab != nil
}

var body: some View {
HStack(spacing: 0) {
// Scrollable tabs with fade overlays
Expand Down Expand Up @@ -136,17 +167,7 @@ struct TabBarView: View {
.overlay(alignment: .trailing) {
let trailing = max(0, containerGeo.size.width - contentWidth)
if trailing >= 1 {
Color.clear
.frame(width: trailing, height: TabBarMetrics.tabHeight)
.contentShape(Rectangle())
.onDrop(of: [.tabTransfer], delegate: TabDropDelegate(
targetIndex: pane.tabs.count,
pane: pane,
bonsplitController: controller,
controller: splitViewController,
dropTargetIndex: $dropTargetIndex,
dropLifecycle: $dropLifecycle
))
trailingInteractionView(width: trailing)
}
}
.coordinateSpace(name: "tabScroll")
Expand Down Expand Up @@ -175,13 +196,13 @@ struct TabBarView: View {

// Split buttons
if showSplitButtons {
splitButtons
.saturation(tabBarSaturation)
splitButtonsArea
}
}
.frame(height: TabBarMetrics.barHeight)
.coordinateSpace(name: "tabBar")
.contentShape(Rectangle())
.accessibilityIdentifier("paneTabBar")
.background(tabBarBackground)
.background(
TabBarHostWindowReader { window in
Expand Down Expand Up @@ -212,6 +233,9 @@ struct TabBarView: View {
.onDisappear {
controlKeyMonitor.stop()
}
.onHover { hovering in
isHoveringTabBar = hovering
}
}

// MARK: - Tab Item
Expand Down Expand Up @@ -438,8 +462,46 @@ struct TabBarView: View {
.offset(x: -1)
}

@ViewBuilder
private func trailingInteractionView(width: CGFloat) -> some View {
if shouldUseTabBarDragRegion && !isTabDragActive {
TabBarWindowDragRegion()
.frame(width: width, height: TabBarMetrics.tabHeight)
Comment on lines +753 to +755
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep trailing tab-drop area active for external drags

When workspaceTitlebarVisible is false, this condition switches the trailing interaction area from an onDrop target to TabBarWindowDragRegion unless isTabDragActive is set. isTabDragActive only reflects local drag state (draggingTab/activeDragTab), so cross-controller drags (the same external-drag case already handled in validateDrop) leave this region non-droppable. In that scenario, dropping after the last tab in the wide trailing gap stops working and users are forced to hit only the tiny 30px end drop zone.

Useful? React with 👍 / 👎.

} else {
Color.clear
.frame(width: width, height: TabBarMetrics.tabHeight)
.contentShape(Rectangle())
.onDrop(of: [.tabTransfer], delegate: TabDropDelegate(
targetIndex: pane.tabs.count,
pane: pane,
bonsplitController: controller,
controller: splitViewController,
dropTargetIndex: $dropTargetIndex,
dropLifecycle: $dropLifecycle
))
}
}

// MARK: - Split Buttons

@ViewBuilder
private var splitButtonsArea: some View {
ZStack(alignment: .trailing) {
if !shouldShowSplitButtonsNow && shouldUseTabBarDragRegion && !isTabDragActive {
TabBarWindowDragRegion()
.frame(maxWidth: .infinity, maxHeight: .infinity)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Constrain hidden controls drag region width

In splitButtonsArea, the hidden-state drag region uses .frame(maxWidth: .infinity), which makes this HStack child flexible and able to absorb extra horizontal space instead of staying the split-buttons width. In the onHover + hidden-titlebar path, that can steal width from the scrollable tabs area and compress/hide tabs while controls are hidden. The drag region should be constrained to the controls slot width rather than allowed to expand infinitely.

Useful? React with 👍 / 👎.

Comment on lines +776 to +778
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep a drag hotspot when titlebar is hidden

This condition only adds TabBarWindowDragRegion when the split controls are hidden, so with workspaceTitlebarVisible == false and the default controls mode (always), the controls slot is never draggable; if tabs also overflow (so there is no trailing gap), the tab bar has no window-drag target at all. Because this commit also switches pane hosts to BonsplitHostingView (mouseDownCanMoveWindow = false), users in that configuration can no longer drag the window from the pane tab bar.

Useful? React with 👍 / 👎.

}

splitButtons
.saturation(tabBarSaturation)
.opacity(shouldShowSplitButtonsNow ? 1 : 0)
.allowsHitTesting(shouldShowSplitButtonsNow)
.animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: shouldShowSplitButtonsNow)
}
.contentShape(Rectangle())
.accessibilityIdentifier("paneTabBarControlsRegion")
}

@ViewBuilder
private var splitButtons: some View {
let tooltips = controller.configuration.appearance.splitButtonTooltips
Expand All @@ -451,6 +513,7 @@ struct TabBarView: View {
.font(.system(size: 12))
}
.buttonStyle(SplitActionButtonStyle(appearance: appearance))
.accessibilityIdentifier("paneTabBarControl.newTerminal")
.safeHelp(tooltips.newTerminal)

Button {
Expand All @@ -460,6 +523,7 @@ struct TabBarView: View {
.font(.system(size: 12))
}
.buttonStyle(SplitActionButtonStyle(appearance: appearance))
.accessibilityIdentifier("paneTabBarControl.newBrowser")
.safeHelp(tooltips.newBrowser)

Button {
Expand All @@ -470,6 +534,7 @@ struct TabBarView: View {
.font(.system(size: 12))
}
.buttonStyle(SplitActionButtonStyle(appearance: appearance))
.accessibilityIdentifier("paneTabBarControl.splitRight")
.safeHelp(tooltips.splitRight)

Button {
Expand All @@ -480,6 +545,7 @@ struct TabBarView: View {
.font(.system(size: 12))
}
.buttonStyle(SplitActionButtonStyle(appearance: appearance))
.accessibilityIdentifier("paneTabBarControl.splitDown")
.safeHelp(tooltips.splitDown)
}
.padding(.trailing, 8)
Expand Down
Loading
Loading