Repository navigation
Fix minimal tab drag window movement #124
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
Changes from 4 commits
ce88069
2979b6f
c44cc83
207186b
ff55eda
e1d9954
b5dd1f0
e1a9e76
f8233a4
ba80421
dc78f5c
02db30f
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 |
|---|---|---|
|
|
@@ -47,6 +47,56 @@ public enum BonsplitTabBarHitRegionRegistry { | |
| } | ||
| } | ||
|
|
||
| public protocol BonsplitTabItemHitRegionProviding: AnyObject { | ||
| func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool | ||
| } | ||
|
|
||
| public enum BonsplitTabItemHitRegionRegistry { | ||
| private static let lock = NSLock() | ||
| private static let registeredViews = NSHashTable<NSView>.weakObjects() | ||
|
|
||
| static func register(_ view: NSView) { | ||
| lock.lock() | ||
| registeredViews.add(view) | ||
| lock.unlock() | ||
| } | ||
|
|
||
| static func unregister(_ view: NSView) { | ||
| lock.lock() | ||
| registeredViews.remove(view) | ||
| lock.unlock() | ||
| } | ||
|
|
||
| private static func snapshot() -> [NSView] { | ||
| lock.lock() | ||
| let views = registeredViews.allObjects | ||
| lock.unlock() | ||
| return views | ||
| } | ||
|
|
||
| private static func isVisibleInHierarchy(_ view: NSView) -> Bool { | ||
| var current: NSView? = view | ||
| while let candidate = current { | ||
| guard !candidate.isHidden, candidate.alphaValue > 0 else { return false } | ||
| current = candidate.superview | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| public static func containsWindowPoint(_ windowPoint: CGPoint, in window: NSWindow) -> Bool { | ||
| for view in snapshot() { | ||
| guard view.window === window, | ||
| isVisibleInHierarchy(view), | ||
| let provider = view as? BonsplitTabItemHitRegionProviding else { continue } | ||
| let localPoint = view.convert(windowPoint, from: nil) | ||
| if provider.containsBonsplitTabItemHit(localPoint: localPoint) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| private struct SelectedTabFramePreferenceKey: PreferenceKey { | ||
| static let defaultValue: CGRect? = nil | ||
|
|
||
|
|
@@ -329,6 +379,7 @@ enum TabBarStyling { | |
| struct TabBarLayout: Equatable { | ||
| let barHeight: CGFloat | ||
| let availableWidth: CGFloat | ||
| let tabContentWidthExcludingSplitButtonLane: CGFloat? | ||
| let splitButtonCount: Int | ||
| let splitButtonLaneVisible: Bool | ||
| let reservesSplitButtonLane: Bool | ||
|
|
@@ -337,13 +388,15 @@ struct TabBarLayout: Equatable { | |
| init( | ||
| tabBarHeight: CGFloat, | ||
| availableWidth: CGFloat = 0, | ||
| tabContentWidthExcludingSplitButtonLane: CGFloat? = nil, | ||
| splitButtonCount: Int, | ||
| splitButtonLaneVisible: Bool, | ||
| reservesSplitButtonLane: Bool, | ||
| measuredSplitButtonLaneWidth: CGFloat = 0 | ||
| ) { | ||
| self.barHeight = max(1, tabBarHeight) | ||
| self.availableWidth = max(0, availableWidth) | ||
| self.tabContentWidthExcludingSplitButtonLane = tabContentWidthExcludingSplitButtonLane.map { max(0, $0) } | ||
| self.splitButtonCount = max(0, splitButtonCount) | ||
| self.splitButtonLaneVisible = splitButtonLaneVisible | ||
| self.reservesSplitButtonLane = reservesSplitButtonLane | ||
|
|
@@ -362,7 +415,16 @@ struct TabBarLayout: Equatable { | |
|
|
||
| var maximumSplitButtonLaneWidth: CGFloat { | ||
| guard availableWidth > 0 else { return fullSplitButtonLaneWidth } | ||
| return availableWidth * TabBarStyling.maximumSplitButtonLaneWidthFraction | ||
| let fractionLimit = availableWidth * TabBarStyling.maximumSplitButtonLaneWidthFraction | ||
| return max(fractionLimit, trailingWhitespaceBeforeSplitButtonLane) | ||
| } | ||
|
|
||
| var trailingWhitespaceBeforeSplitButtonLane: CGFloat { | ||
| guard availableWidth > 0, | ||
| let tabContentWidthExcludingSplitButtonLane else { | ||
| return 0 | ||
| } | ||
| return max(0, availableWidth - tabContentWidthExcludingSplitButtonLane) | ||
| } | ||
|
|
||
| var visibleSplitButtonLaneWidth: CGFloat { | ||
|
|
@@ -761,6 +823,7 @@ struct TabBarView: View { | |
| @State private var dropLifecycle: TabDropLifecycle = .idle | ||
| @State private var scrollOffset: CGFloat = 0 | ||
| @State private var contentWidth: CGFloat = 0 | ||
| @State private var tabContentWidthExcludingSplitButtonLane: CGFloat? | ||
| @State private var containerWidth: CGFloat = 0 | ||
| @State private var selectedTabFrameInBar: CGRect? | ||
| @State private var tabFramesInBar: [UUID: CGRect] = [:] | ||
|
|
@@ -803,6 +866,7 @@ struct TabBarView: View { | |
| TabBarLayout( | ||
| tabBarHeight: appearance.tabBarHeight, | ||
| availableWidth: containerWidth, | ||
| tabContentWidthExcludingSplitButtonLane: tabContentWidthExcludingSplitButtonLane, | ||
| splitButtonCount: visibleSplitButtons.count, | ||
| splitButtonLaneVisible: shouldShowSplitButtons, | ||
| reservesSplitButtonLane: showSplitButtons && !isMinimalMode, | ||
|
|
@@ -944,13 +1008,11 @@ struct TabBarView: View { | |
| GeometryReader { contentGeo in | ||
| Color.clear | ||
| .onChange(of: contentGeo.frame(in: .named("tabScroll"))) { _, newFrame in | ||
| scrollOffset = -newFrame.minX | ||
| contentWidth = newFrame.width | ||
| updateTabScrollContent(frame: newFrame) | ||
| } | ||
| .onAppear { | ||
| let frame = contentGeo.frame(in: .named("tabScroll")) | ||
| scrollOffset = -frame.minX | ||
| contentWidth = frame.width | ||
| updateTabScrollContent(frame: frame) | ||
| } | ||
| } | ||
| ) | ||
|
|
@@ -1047,6 +1109,7 @@ struct TabBarView: View { | |
| } | ||
| .background(TabBarDragAndHoverView( | ||
| isMinimalMode: isMinimalMode, | ||
| tabFrames: Array(tabFramesInBar.values), | ||
| onDoubleClick: { | ||
| performNewTerminalSplitButtonAction() | ||
| }, | ||
|
|
@@ -1536,6 +1599,12 @@ struct TabBarView: View { | |
| splitButtonContentWidth = frame.width | ||
| } | ||
|
|
||
| private func updateTabScrollContent(frame: CGRect) { | ||
| scrollOffset = -frame.minX | ||
| contentWidth = frame.width | ||
| tabContentWidthExcludingSplitButtonLane = max(0, frame.width - tabBarLayout.trailingTabContentInset) | ||
| } | ||
|
|
||
| @ViewBuilder | ||
| private var splitButtonRow: some View { | ||
| let tooltips = controller.configuration.appearance.splitButtonTooltips | ||
|
|
@@ -2246,25 +2315,29 @@ private struct TabBarManualReorderTrackingView: NSViewRepresentable { | |
| /// this view only receives hits in truly empty space. | ||
| private struct TabBarDragAndHoverView: NSViewRepresentable { | ||
| let isMinimalMode: Bool | ||
| let tabFrames: [CGRect] | ||
| let onDoubleClick: () -> Bool | ||
| let onHoverChanged: (Bool) -> Void | ||
|
|
||
| func makeNSView(context: Context) -> TabBarBackgroundNSView { | ||
| let view = TabBarBackgroundNSView() | ||
| view.isMinimalMode = isMinimalMode | ||
| view.tabFrames = tabFrames | ||
| view.onDoubleClick = onDoubleClick | ||
| view.onHoverChanged = onHoverChanged | ||
| return view | ||
| } | ||
|
|
||
| func updateNSView(_ nsView: TabBarBackgroundNSView, context: Context) { | ||
| nsView.isMinimalMode = isMinimalMode | ||
| nsView.tabFrames = tabFrames | ||
| nsView.onDoubleClick = onDoubleClick | ||
| nsView.onHoverChanged = onHoverChanged | ||
| } | ||
|
|
||
| final class TabBarBackgroundNSView: NSView { | ||
| final class TabBarBackgroundNSView: NSView, BonsplitTabItemHitRegionProviding { | ||
| var isMinimalMode = false | ||
| nonisolated(unsafe) var tabFrames: [CGRect] = [] | ||
|
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.
|
||
| var onDoubleClick: (() -> Bool)? | ||
| var onHoverChanged: ((Bool) -> Void)? | ||
| private var hoverTrackingArea: NSTrackingArea? | ||
|
|
@@ -2279,15 +2352,18 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { | |
| removeLocalMouseMonitor() | ||
| removeWindowObservers() | ||
| BonsplitTabBarHitRegionRegistry.unregister(self) | ||
| BonsplitTabItemHitRegionRegistry.unregister(self) | ||
| } | ||
|
|
||
| override func viewDidMoveToWindow() { | ||
| super.viewDidMoveToWindow() | ||
| BonsplitTabBarHitRegionRegistry.unregister(self) | ||
| BonsplitTabItemHitRegionRegistry.unregister(self) | ||
| removeWindowObservers() | ||
| if let window { | ||
| window.acceptsMouseMovedEvents = true | ||
| BonsplitTabBarHitRegionRegistry.register(self) | ||
| BonsplitTabItemHitRegionRegistry.register(self) | ||
| installWindowObservers() | ||
| installLocalMouseMonitorIfNeeded() | ||
| syncHoverStateToCurrentMouseLocation() | ||
|
|
@@ -2301,9 +2377,15 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { | |
| super.viewDidMoveToSuperview() | ||
| if superview == nil { | ||
| BonsplitTabBarHitRegionRegistry.unregister(self) | ||
| BonsplitTabItemHitRegionRegistry.unregister(self) | ||
| } | ||
| } | ||
|
|
||
| nonisolated func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool { | ||
| let paddedFrames = tabFrames.map { $0.insetBy(dx: -2, dy: -2) } | ||
| return paddedFrames.contains { $0.contains(localPoint) } | ||
| } | ||
|
|
||
| override func updateTrackingAreas() { | ||
| super.updateTrackingAreas() | ||
| if let existing = hoverTrackingArea { | ||
|
|
@@ -2338,6 +2420,13 @@ private struct TabBarDragAndHoverView: NSViewRepresentable { | |
| super.mouseDown(with: event) | ||
| return | ||
| } | ||
| if containsBonsplitTabItemHit(localPoint: convert(event.locationInWindow, from: nil)) { | ||
| #if DEBUG | ||
| dlog("tab.bar.bg.mouseDown skipped reason=tabItem") | ||
| #endif | ||
| super.mouseDown(with: event) | ||
| return | ||
| } | ||
| if event.clickCount >= 2 { | ||
| if isMinimalMode { | ||
| let action = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?["AppleActionOnDoubleClick"] as? String | ||
|
|
||
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.
containsWindowPointaccesses@MainActorAPIs without actor annotationisVisibleInHierarchyandcontainsWindowPointreadview.window,view.superview,view.isHidden,view.alphaValue, and callview.convert(_:from:)— all@MainActor-isolated AppKit properties. The method carries no@MainActorannotation and ispublic, so callers in cmux or other consumers can invoke it from a non-main-actor context without a compiler warning. Combined withtabFramesbeingnonisolated(unsafe), a concurrent write during a background-thread call tocontainsWindowPointwould be an undetected data race. Adding@MainActorto the method (and to the parallelBonsplitTabBarHitRegionRegistry.containsWindowPoint) would close the gap without any behavioural change for existing callers, which are all AppKit event handlers.