diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 76ea66de..61597169 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; + AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; @@ -267,6 +268,7 @@ D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; + AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = ""; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; @@ -522,6 +524,7 @@ FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, + AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */, ); path = cmuxUITests; sourceTree = ""; @@ -789,6 +792,7 @@ FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */, + AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index fcd3a6be..af26a118 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2348,7 +2348,7 @@ struct ContentView: View { } private var effectiveTitlebarPadding: CGFloat { - isMinimalMode ? 0 : titlebarPadding + isMinimalMode ? -titlebarPadding : titlebarPadding } private var terminalContent: some View { @@ -2512,6 +2512,15 @@ struct ContentView: View { } } + private func syncTrafficLightInset() { + let inset: CGFloat = (isMinimalMode && !sidebarState.isVisible) ? 80 : 0 + for tab in tabManager.tabs { + if tab.bonsplitController.configuration.appearance.tabBarLeadingInset != inset { + tab.bonsplitController.configuration.appearance.tabBarLeadingInset = inset + } + } + } + private func updateTitlebarText() { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { @@ -2661,6 +2670,7 @@ struct ContentView: View { syncSidebarSelectedWorkspaceIds() applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs) updateTitlebarText() + syncTrafficLightInset() // Startup recovery (#399): if session restore or a race condition leaves the // view in a broken state (empty tabs, no selection, unmounted workspaces), @@ -3079,6 +3089,11 @@ struct ContentView: View { TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() } updateSidebarResizerBandState() + syncTrafficLightInset() + }) + + view = AnyView(view.onChange(of: isMinimalMode) { _, _ in + syncTrafficLightInset() }) view = AnyView(view.onChange(of: sidebarState.persistedWidth) { newValue in @@ -3110,11 +3125,11 @@ struct ContentView: View { view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) window.titlebarAppearsTransparent = true - // Do not make the entire background draggable; it interferes with drag gestures - // like sidebar tab reordering in multi-window mode. + // Keep window immovable; the sidebar's WindowDragHandleView handles + // drag-to-move via performDrag with temporary movable override. + // isMovableByWindowBackground=true breaks tab reordering, and + // isMovable=true blocks clicks on sidebar buttons in minimal mode. window.isMovableByWindowBackground = false - // Keep the window immovable by default so titlebar controls (like the folder icon) - // cannot accidentally initiate native window drags. window.isMovable = false window.styleMask.insert(.fullSizeContentView) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 344b3a2b..fd424bdb 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -821,7 +821,9 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont queue: .main ) { [weak self] _ in self?.applyWorkspaceTitlebarVisibility() - self?.scheduleSizeUpdate(invalidateFittingSize: true) + if self?.showsWorkspaceTitlebar == true { + self?.restoreSizeAfterMinimalMode() + } } applyWorkspaceTitlebarVisibility() @@ -918,14 +920,27 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private func applyWorkspaceTitlebarVisibility() { let shouldShow = showsWorkspaceTitlebar + self.isHidden = !shouldShow view.isHidden = !shouldShow + view.alphaValue = shouldShow ? 1 : 0 if !shouldShow { preferredContentSize = .zero - containerView.frame = .zero - hostingView.frame = .zero } } + /// Restore the accessory size after it was zeroed in minimal mode. + /// Seeds the hosting view with a non-zero frame so fittingSize returns + /// valid values even after the view was collapsed. + private func restoreSizeAfterMinimalMode() { + guard showsWorkspaceTitlebar else { return } + let seed = cachedFittingSize ?? NSSize(width: 200, height: 28) + if hostingView.frame.size == .zero || containerView.frame.size == .zero { + containerView.frame.size = seed + hostingView.frame.size = seed + } + scheduleSizeUpdate(invalidateFittingSize: true) + } + func toggleNotificationsPopover(animated: Bool = true, externalAnchor: NSView? = nil) { if notificationsPopover.isShown { notificationsPopover.performClose(nil) @@ -1202,6 +1217,7 @@ final class UpdateTitlebarAccessoryController { private var startupScanWorkItems: [DispatchWorkItem] = [] private let controlsIdentifier = NSUserInterfaceItemIdentifier("cmux.titlebarControls") private let controlsControllers = NSHashTable.weakObjects() + private var lastKnownPresentationMode: WorkspacePresentationModeSettings.Mode = WorkspacePresentationModeSettings.mode() init(viewModel: UpdateViewModel) { self.updateViewModel = viewModel @@ -1249,10 +1265,45 @@ final class UpdateTitlebarAccessoryController { } }) + // Re-evaluate all windows when the presentation mode changes so that + // accessories are removed in minimal mode and re-attached in standard mode. + observers.append(center.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.reattachIfPresentationModeChanged() + } + }) + // We intentionally do not rely on "window became visible" notifications here: // AppKit does not provide a stable cross-SDK API for this. Startup scans handle this case. } + private func reattachIfPresentationModeChanged() { + let currentMode = WorkspacePresentationModeSettings.mode() + guard currentMode != lastKnownPresentationMode else { return } + lastKnownPresentationMode = currentMode + // Ensure accessories exist on all windows. TitlebarControlsAccessoryViewController + // handles its own visibility (hidden in minimal, visible in standard) via its + // UserDefaults observer, so we just need to make sure it's attached. + attachToExistingWindows() + + // When switching back to standard mode while a window is in fullscreen, + // hide the accessories because fullscreen uses SwiftUI overlay controls. + if currentMode == .standard { + let controlsId = self.controlsIdentifier + for window in NSApp.windows where window.styleMask.contains(.fullScreen) { + for accessory in window.titlebarAccessoryViewControllers + where accessory.view.identifier == controlsId { + accessory.isHidden = true + accessory.view.alphaValue = 0 + } + } + } + } + private func attachToExistingWindows() { for window in NSApp.windows { attachIfNeeded(to: window) @@ -1308,10 +1359,9 @@ final class UpdateTitlebarAccessoryController { pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) - guard !WorkspacePresentationModeSettings.isMinimal() else { - removeAccessoryIfPresent(from: window) - return - } + // Don't remove accessories in minimal mode. TitlebarControlsAccessoryViewController + // hides itself and zeros its frame via its own UserDefaults observer. Keeping it + // attached avoids fragile remove/re-add cycles on mode toggle. guard !attachedWindows.contains(window) else { return } diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 462b036f..5c9be301 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -11,6 +11,7 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { private var commandLabels: [ObjectIdentifier: NSTextField] = [:] private var observers: [NSObjectProtocol] = [] private let focusedCommandUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) + private var lastKnownPresentationMode: WorkspacePresentationModeSettings.Mode = WorkspacePresentationModeSettings.mode() override init() { super.init() @@ -61,6 +62,48 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { self?.attach(to: window) } }) + + observers.append(center.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateToolbarVisibilityIfNeeded() + } + }) + } + + private func updateToolbarVisibilityIfNeeded() { + let currentMode = WorkspacePresentationModeSettings.mode() + guard currentMode != lastKnownPresentationMode else { return } + lastKnownPresentationMode = currentMode + let isMinimal = currentMode == .minimal + for window in NSApp.windows { + if isMinimal { + window.toolbar = nil + } else { + attach(to: window) + } + } + // After toolbar changes, force titlebar accessories to recalculate. + // Toolbar removal/re-addition changes the titlebar geometry, and + // accessories hidden via isHidden need a layout pass to reappear. + if !isMinimal { + DispatchQueue.main.async { + for window in NSApp.windows { + for accessory in window.titlebarAccessoryViewControllers { + if !accessory.isHidden { + accessory.view.needsLayout = true + accessory.view.superview?.needsLayout = true + } + } + window.contentView?.needsLayout = true + window.contentView?.superview?.needsLayout = true + window.invalidateShadow() + } + } + } } private func attachToExistingWindows() { @@ -71,6 +114,7 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { private func attach(to window: NSWindow) { guard window.toolbar == nil else { return } + guard !WorkspacePresentationModeSettings.isMinimal() else { return } let toolbar = NSToolbar(identifier: NSToolbar.Identifier("cmux.toolbar")) toolbar.delegate = self toolbar.displayMode = .iconOnly diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 9638443a..ed1d961a 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -263,6 +263,7 @@ struct WorkspaceContentView: View { // AppKit-backed views can still intercept drags. Disable drop acceptance for them. let _ = { workspace.bonsplitController.isInteractive = isWorkspaceInputActive }() + // Wire up file drop handling so bonsplit's PaneDragContainerView can forward // Finder file drops to the correct terminal panel. let _ = { @@ -379,12 +380,6 @@ struct WorkspaceContentView: View { if isMinimalMode { bonsplitView .ignoresSafeArea(.container, edges: .top) - .overlay(alignment: .top) { - if isWorkspaceInputActive { - TitlebarDoubleClickMonitorView() - .frame(height: WorkspaceTitlebarInteractionMetrics.minimalModeTopStripHeight) - } - } } else { bonsplitView } diff --git a/vendor/bonsplit b/vendor/bonsplit index 1610b457..447ac42b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 1610b457bc44bb1d50dd246792f8724ce21a7c81 +Subproject commit 447ac42b45256bdf333659d2dbe955afcaa87f6b