Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
}
}
146 changes: 142 additions & 4 deletions Sources/Bonsplit/Internal/Views/PaneContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +22 to +25
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 Restore movability before swapping monitored window

When viewDidMoveToWindow detects a window change, it assigns monitoredWindow = window (and installs a new monitor) before restoring any pending previousWindowMovableState. If this view has already suppressed dragging (window.isMovable = false) and is detached/reparented before the matching leftMouseUp arrives, 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 overwriting monitoredWindow.

Useful? React with 👍 / 👎.

}

if window == nil {
restoreWindowDraggingIfNeeded()
}
Comment on lines +22 to +30
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 13, 2026

Choose a reason for hiding this comment

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

P1: viewDidMoveToWindow clears the tracked window before restoring isMovable, which can leave a window permanently non-movable; it also installs an event monitor when detached (window == nil).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Bonsplit/Internal/Views/PaneContainerView.swift, line 22:

<comment>`viewDidMoveToWindow` clears the tracked window before restoring `isMovable`, which can leave a window permanently non-movable; it also installs an event monitor when detached (`window == nil`).</comment>

<file context>
@@ -3,15 +3,101 @@ import UniformTypeIdentifiers
+    override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+
+        if window !== monitoredWindow {
+            removeEventMonitor()
+            monitoredWindow = window
</file context>
Suggested change
if window !== monitoredWindow {
removeEventMonitor()
monitoredWindow = window
installEventMonitor()
}
if window == nil {
restoreWindowDraggingIfNeeded()
}
if window !== monitoredWindow {
restoreWindowDraggingIfNeeded()
removeEventMonitor()
monitoredWindow = window
if window != nil {
installEventMonitor()
}
}
Fix with Cubic

}

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
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 Ignore hidden tab bars in drag-suppression monitor

The new local mouse monitor suppresses window.isMovable whenever the cursor is inside this view’s bounds, but it never checks whether the tab bar is currently hidden/inactive. In this codebase, inactive workspaces are hidden via isHidden (not detached), so their TabBarInteractionContainerView instances still receive monitor callbacks; if their cached frames overlap the active workspace, they can incorrectly disable window dragging for visible UI interactions. Add a visibility/hidden-ancestor guard before calling suppressWindowDraggingIfNeeded so only the active tab bar can affect window movability.

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
Expand Down Expand Up @@ -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
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>>?
}
}
Loading
Loading