-
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 9 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 |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,142 @@ import SwiftUI | |||||||||||||||||||||||||||||||||||
| import UniformTypeIdentifiers | ||||||||||||||||||||||||||||||||||||
| import AppKit | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private final class TabBarInteractionContainerView: NSView { | ||||||||||||||||||||||||||||||||||||
| private var eventMonitor: Any? | ||||||||||||||||||||||||||||||||||||
| private weak var monitoredWindow: NSWindow? | ||||||||||||||||||||||||||||||||||||
| private var previousWindowMovableState: Bool? | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| override var mouseDownCanMoveWindow: Bool { false } | ||||||||||||||||||||||||||||||||||||
| override func isAccessibilityElement() -> Bool { true } | ||||||||||||||||||||||||||||||||||||
| override func accessibilityRole() -> NSAccessibility.Role? { .group } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| deinit { | ||||||||||||||||||||||||||||||||||||
| removeEventMonitor() | ||||||||||||||||||||||||||||||||||||
| restoreWindowDraggingIfNeeded() | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| override func viewDidMoveToWindow() { | ||||||||||||||||||||||||||||||||||||
| super.viewDidMoveToWindow() | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if window !== monitoredWindow { | ||||||||||||||||||||||||||||||||||||
| removeEventMonitor() | ||||||||||||||||||||||||||||||||||||
| monitoredWindow = window | ||||||||||||||||||||||||||||||||||||
| installEventMonitor() | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if window == nil { | ||||||||||||||||||||||||||||||||||||
| restoreWindowDraggingIfNeeded() | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+22
to
+30
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. P1: Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private func installEventMonitor() { | ||||||||||||||||||||||||||||||||||||
| guard eventMonitor == nil else { return } | ||||||||||||||||||||||||||||||||||||
| eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) { [weak self] event in | ||||||||||||||||||||||||||||||||||||
| self?.handleLocalMouseEvent(event) ?? event | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private func removeEventMonitor() { | ||||||||||||||||||||||||||||||||||||
| guard let eventMonitor else { return } | ||||||||||||||||||||||||||||||||||||
| NSEvent.removeMonitor(eventMonitor) | ||||||||||||||||||||||||||||||||||||
| self.eventMonitor = nil | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private func handleLocalMouseEvent(_ event: NSEvent) -> NSEvent? { | ||||||||||||||||||||||||||||||||||||
| guard let window = monitoredWindow else { | ||||||||||||||||||||||||||||||||||||
| return event | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if event.window !== window { | ||||||||||||||||||||||||||||||||||||
| if event.type == .leftMouseUp { | ||||||||||||||||||||||||||||||||||||
| restoreWindowDraggingIfNeeded() | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return event | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| let point = convert(event.locationInWindow, from: nil) | ||||||||||||||||||||||||||||||||||||
| switch event.type { | ||||||||||||||||||||||||||||||||||||
| case .leftMouseDown: | ||||||||||||||||||||||||||||||||||||
| if bounds.contains(point), !hitTestRoutesToWindowDragRegion(at: point) { | ||||||||||||||||||||||||||||||||||||
| suppressWindowDraggingIfNeeded(window: window) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+60
to
+63
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.
The new local mouse monitor suppresses Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||||
| case .leftMouseDragged: | ||||||||||||||||||||||||||||||||||||
| if previousWindowMovableState == nil, bounds.contains(point), !hitTestRoutesToWindowDragRegion(at: point) { | ||||||||||||||||||||||||||||||||||||
| suppressWindowDraggingIfNeeded(window: window) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| case .leftMouseUp: | ||||||||||||||||||||||||||||||||||||
| restoreWindowDraggingIfNeeded() | ||||||||||||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return event | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private func hitTestRoutesToWindowDragRegion(at point: NSPoint) -> Bool { | ||||||||||||||||||||||||||||||||||||
| guard let hitView = super.hitTest(point) else { return false } | ||||||||||||||||||||||||||||||||||||
| var current: NSView? = hitView | ||||||||||||||||||||||||||||||||||||
| while let view = current { | ||||||||||||||||||||||||||||||||||||
| if view is TabBarWindowDragRegionView { | ||||||||||||||||||||||||||||||||||||
| return true | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| current = view.superview | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return false | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private func suppressWindowDraggingIfNeeded(window: NSWindow) { | ||||||||||||||||||||||||||||||||||||
| guard previousWindowMovableState == nil else { return } | ||||||||||||||||||||||||||||||||||||
| previousWindowMovableState = window.isMovable | ||||||||||||||||||||||||||||||||||||
| if window.isMovable { | ||||||||||||||||||||||||||||||||||||
| window.isMovable = false | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private func restoreWindowDraggingIfNeeded() { | ||||||||||||||||||||||||||||||||||||
| guard let previousWindowMovableState else { return } | ||||||||||||||||||||||||||||||||||||
| monitoredWindow?.isMovable = previousWindowMovableState | ||||||||||||||||||||||||||||||||||||
| self.previousWindowMovableState = nil | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| 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 = BonsplitHostingView(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: BonsplitHostingView<Content>? | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /// Drop zone positions for creating splits | ||||||||||||||||||||||||||||||||||||
| public enum DropZone: Equatable { | ||||||||||||||||||||||||||||||||||||
| case center | ||||||||||||||||||||||||||||||||||||
|
|
@@ -174,10 +310,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 | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
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
viewDidMoveToWindowdetects a window change, it assignsmonitoredWindow = window(and installs a new monitor) before restoring any pendingpreviousWindowMovableState. If this view has already suppressed dragging (window.isMovable = false) and is detached/reparented before the matchingleftMouseUparrives,restoreWindowDraggingIfNeeded()will restore against the new/nil window instead of the original one, leaving the original window stuck non-movable. Restore the old window state before overwritingmonitoredWindow.Useful? React with 👍 / 👎.