-
Notifications
You must be signed in to change notification settings - Fork 36
Add hover-only pane tab bar controls #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
7b43403
43f6b15
fe83f44
4533859
e96943f
79c0099
1e05cba
3401bd9
8ebf7c8
7f2c13f
a8a0a52
f98559d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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") | ||
|
|
@@ -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 | ||
|
|
@@ -212,6 +233,9 @@ struct TabBarView: View { | |
| .onDisappear { | ||
| controlKeyMonitor.stop() | ||
| } | ||
| .onHover { hovering in | ||
| isHoveringTabBar = hovering | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Tab Item | ||
|
|
@@ -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) | ||
| } 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In Useful? React with 👍 / 👎.
Comment on lines
+776
to
+778
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This condition only adds 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 | ||
|
|
@@ -451,6 +513,7 @@ struct TabBarView: View { | |
| .font(.system(size: 12)) | ||
| } | ||
| .buttonStyle(SplitActionButtonStyle(appearance: appearance)) | ||
| .accessibilityIdentifier("paneTabBarControl.newTerminal") | ||
| .safeHelp(tooltips.newTerminal) | ||
|
|
||
| Button { | ||
|
|
@@ -460,6 +523,7 @@ struct TabBarView: View { | |
| .font(.system(size: 12)) | ||
| } | ||
| .buttonStyle(SplitActionButtonStyle(appearance: appearance)) | ||
| .accessibilityIdentifier("paneTabBarControl.newBrowser") | ||
| .safeHelp(tooltips.newBrowser) | ||
|
|
||
| Button { | ||
|
|
@@ -470,6 +534,7 @@ struct TabBarView: View { | |
| .font(.system(size: 12)) | ||
| } | ||
| .buttonStyle(SplitActionButtonStyle(appearance: appearance)) | ||
| .accessibilityIdentifier("paneTabBarControl.splitRight") | ||
| .safeHelp(tooltips.splitRight) | ||
|
|
||
| Button { | ||
|
|
@@ -480,6 +545,7 @@ struct TabBarView: View { | |
| .font(.system(size: 12)) | ||
| } | ||
| .buttonStyle(SplitActionButtonStyle(appearance: appearance)) | ||
| .accessibilityIdentifier("paneTabBarControl.splitDown") | ||
| .safeHelp(tooltips.splitDown) | ||
| } | ||
| .padding(.trailing, 8) | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
workspaceTitlebarVisibleis false, this condition switches the trailing interaction area from anonDroptarget toTabBarWindowDragRegionunlessisTabDragActiveis set.isTabDragActiveonly reflects local drag state (draggingTab/activeDragTab), so cross-controller drags (the same external-drag case already handled invalidateDrop) 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 👍 / 👎.