Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
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
68 changes: 68 additions & 0 deletions Sources/Bonsplit/Internal/Views/TabBarWindowDragRegion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import AppKit
import SwiftUI

private func performTabBarStandardDoubleClick(window: NSWindow?) -> Bool {
guard let window else { return false }

let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:]
if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() {
switch action {
case "minimize":
window.miniaturize(nil)
return true
case "none":
return false
case "maximize", "zoom":
window.zoom(nil)
return true
default:
break
}
}

if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool,
miniaturizeOnDoubleClick {
window.miniaturize(nil)
return true
}

window.zoom(nil)
return true
}

struct TabBarWindowDragRegion: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
DraggableView()
}

func updateNSView(_ nsView: NSView, context: Context) {}

private final class DraggableView: NSView {
override var mouseDownCanMoveWindow: Bool { false }

override func mouseDown(with event: NSEvent) {
if event.clickCount >= 2, performTabBarStandardDoubleClick(window: window) {
return
}

guard let window else {
super.mouseDown(with: event)
return
}

let previousMovableState = window.isMovable
if !previousMovableState {
window.isMovable = true
}
defer {
if window.isMovable != previousMovableState {
window.isMovable = previousMovableState
}
}

window.performDrag(with: event)
}
}
}
Loading