diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index f39108b84..fbb209f93 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; + C9A10001A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A10002A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; @@ -73,6 +74,7 @@ 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; + D4A1B2C3D4E5F60718000002 /* BonsplitTabDragUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1B2C3D4E5F60718000001 /* BonsplitTabDragUITests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; @@ -158,6 +160,7 @@ A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; + C9A10002A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarVisibilitySettings.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; @@ -209,8 +212,9 @@ A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; - 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; - B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; + 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; + D4A1B2C3D4E5F60718000001 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = ""; }; + B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = ""; }; @@ -357,6 +361,7 @@ A5001012 /* ContentView.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, + C9A10002A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift */, A50012F0 /* Backport.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, A50012F4 /* KeyboardLayout.swift */, @@ -445,6 +450,7 @@ isa = PBXGroup; children = ( B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */, + D4A1B2C3D4E5F60718000001 /* BonsplitTabDragUITests.swift */, B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */, B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */, B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, @@ -628,6 +634,7 @@ A5001002 /* ContentView.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, + C9A10001A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift in Sources */, A50012F1 /* Backport.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, @@ -684,6 +691,7 @@ buildActionMask = 2147483647; files = ( B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */, + D4A1B2C3D4E5F60718000002 /* BonsplitTabDragUITests.swift in Sources */, B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */, B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */, B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 54aa5c859..7524e2faf 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -73093,6 +73093,193 @@ } } } + }, + "settings.app.showWorkspaceTitlebar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Workspace Title Bar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースのタイトルバーを表示" + } + } + } + }, + "settings.app.showWorkspaceTitlebar.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide the folder/title strip and drag from empty space in the top pane tab bar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダとタイトルの帯を隠し、上部のペインタブバーの空き領域からウィンドウをドラッグできます。" + } + } + } + }, + "settings.app.showWorkspaceTitlebar.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show the folder and active title above pane tabs." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインタブの上にフォルダ名と現在のタイトルを表示します。" + } + } + } + }, + "settings.app.titlebarControls": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Titlebar Controls" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイトルバーのコントロール" + } + } + } + }, + "settings.app.titlebarControls.always": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always Visible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常に表示" + } + } + } + }, + "settings.app.titlebarControls.hover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show on Hover" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホバー時に表示" + } + } + } + }, + "settings.app.titlebarControls.subtitleAlways": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep the sidebar, notifications, and new workspace buttons visible." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバー、通知、新規ワークスペースのボタンを常に表示します。" + } + } + } + }, + "settings.app.titlebarControls.subtitleHover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide titlebar buttons until the pointer reaches them." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポインタが近づくまでタイトルバーのボタンを隠します。" + } + } + } + }, + "settings.app.paneTabBarControls": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pane Tab Bar Controls" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインタブバーのコントロール" + } + } + } + }, + "settings.app.paneTabBarControls.subtitleAlways": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep the pane tab bar's new tab and split buttons visible." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインタブバーの新規タブボタンと分割ボタンを常に表示します。" + } + } + } + }, + "settings.app.paneTabBarControls.subtitleHover": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide the pane tab bar's new tab and split buttons until you hover the bar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインタブバーにホバーするまで、新規タブボタンと分割ボタンを隠します。" + } + } + } } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e441d37c3..7f7471f5b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,6 +9,31 @@ import Combine import ObjectiveC.runtime import Darwin +final class MainWindowHostingView: NSHostingView { + private let zeroSafeAreaLayoutGuide = NSLayoutGuide() + + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var safeAreaRect: NSRect { bounds } + override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + override var mouseDownCanMoveWindow: Bool { false } + + 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") + } +} + #if DEBUG enum CmuxTypingTiming { static let isEnabled: Bool = { @@ -1970,6 +1995,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? private var jumpUnreadFocusObserver: NSObjectProtocol? private var didSetupGotoSplitUITest = false + private var didSetupBonsplitTabDragUITest = false + private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? @@ -2343,6 +2370,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() + setupBonsplitTabDragUITestIfNeeded() setupMultiWindowNotificationsUITestIfNeeded() // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. @@ -3458,6 +3486,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setActiveMainWindow(window) } + refreshTitlebarAccessory(for: window) + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) if !isTerminatingApp { _ = saveSessionSnapshot(includeScrollback: false) @@ -4951,6 +4981,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible } + func sidebarVisibility(for window: NSWindow?) -> Bool? { + guard let window else { return nil } + return mainWindowContexts.values.first(where: { $0.window === window })?.sidebarState.isVisible + } + @objc func openNewMainWindow(_ sender: Any?) { _ = createMainWindow() } @@ -5371,7 +5406,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { window.center() } - window.contentView = NSHostingView(rootView: root) + window.contentView = MainWindowHostingView(rootView: root) // Apply shared window styling. attachUpdateAccessory(to: window) @@ -6419,6 +6454,183 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func setupBonsplitTabDragUITestIfNeeded() { + guard !didSetupBonsplitTabDragUITest else { return } + didSetupBonsplitTabDragUITest = true + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1" else { return } + guard tabManager != nil else { return } + let startWithHiddenSidebar = env["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] == "1" + + let deadline = Date().addingTimeInterval(20.0) + func hasMainTerminalWindow() -> Bool { + NSApp.windows.contains { window in + guard let raw = window.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + } + } + + func runSetupWhenWindowReady() { + guard Date() < deadline else { + writeBonsplitTabDragUITestData(["setupError": "Timed out waiting for main window"]) + return + } + guard hasMainTerminalWindow() else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + runSetupWhenWindowReady() + } + return + } + if let mainWindow = NSApp.windows.first(where: { window in + guard let raw = window.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + }) { + let screenFrame = mainWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame + if let screenFrame { + let targetSize = NSSize(width: min(960, screenFrame.width - 80), height: min(720, screenFrame.height - 80)) + let targetOrigin = NSPoint( + x: screenFrame.minX + 40, + y: screenFrame.maxY - 40 - targetSize.height + ) + let targetFrame = NSRect(origin: targetOrigin, size: targetSize) + if !mainWindow.frame.equalTo(targetFrame) { + mainWindow.setFrame(targetFrame, display: true) + } + } + } + guard let tabManager = self.tabManager, + let workspace = tabManager.selectedWorkspace ?? tabManager.tabs.first, + let alphaPanelId = workspace.focusedPanelId else { + self.writeBonsplitTabDragUITestData(["setupError": "Missing initial workspace or panel"]) + return + } + + let workspaceTitle = "UITest Workspace" + let alphaTitle = "UITest Alpha" + let betaTitle = "UITest Beta" + tabManager.setCustomTitle(tabId: workspace.id, title: workspaceTitle) + workspace.setPanelCustomTitle(panelId: alphaPanelId, title: alphaTitle) + tabManager.newSurface() + + guard let betaPanelId = workspace.focusedPanelId, betaPanelId != alphaPanelId else { + self.writeBonsplitTabDragUITestData(["setupError": "Failed to create second surface"]) + return + } + + workspace.setPanelCustomTitle(panelId: betaPanelId, title: betaTitle) + if startWithHiddenSidebar { + self.sidebarState?.isVisible = false + if let mainWindow = NSApp.windows.first(where: { window in + guard let raw = window.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + }) { + self.refreshTitlebarAccessory(for: mainWindow) + } + } + self.writeBonsplitTabDragUITestData([ + "ready": "1", + "sidebarVisible": startWithHiddenSidebar ? "0" : "1", + "workspaceId": workspace.id.uuidString, + "workspaceTitle": workspaceTitle, + "alphaTitle": alphaTitle, + "betaTitle": betaTitle, + "alphaPanelId": alphaPanelId.uuidString, + "betaPanelId": betaPanelId.uuidString, + ]) + self.startBonsplitTabDragUITestRecorder( + workspaceId: workspace.id, + alphaPanelId: alphaPanelId, + betaPanelId: betaPanelId + ) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard self != nil else { return } + runSetupWhenWindowReady() + } + } + + private func bonsplitTabDragUITestDataPath() -> String? { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1", + let path = env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"], + !path.isEmpty else { + return nil + } + return path + } + + private func startBonsplitTabDragUITestRecorder( + workspaceId: UUID, + alphaPanelId: UUID, + betaPanelId: UUID + ) { + bonsplitTabDragUITestRecorder?.cancel() + bonsplitTabDragUITestRecorder = nil + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(100)) + timer.setEventHandler { [weak self] in + self?.recordBonsplitTabDragUITestState( + workspaceId: workspaceId, + alphaPanelId: alphaPanelId, + betaPanelId: betaPanelId + ) + } + bonsplitTabDragUITestRecorder = timer + timer.resume() + } + + private func recordBonsplitTabDragUITestState( + workspaceId: UUID, + alphaPanelId: UUID, + betaPanelId: UUID + ) { + guard let tabManager else { return } + guard let workspace = (tabManager.tabs.first { $0.id == workspaceId } ?? tabManager.selectedWorkspace ?? tabManager.tabs.first) else { + return + } + + let trackedPaneId = workspace.paneId(forPanelId: alphaPanelId) + ?? workspace.paneId(forPanelId: betaPanelId) + ?? workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first + guard let trackedPaneId else { return } + + let titles: [String] = workspace.bonsplitController.tabs(inPane: trackedPaneId).compactMap { tab in + guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { return nil } + return workspace.panelTitle(panelId: panelId) + } + let selectedTitle = workspace.bonsplitController.selectedTab(inPane: trackedPaneId) + .flatMap { workspace.panelIdFromSurfaceId($0.id) } + .flatMap { workspace.panelTitle(panelId: $0) } ?? "" + + writeBonsplitTabDragUITestData([ + "trackedPaneId": trackedPaneId.description, + "trackedPaneTabTitles": titles.joined(separator: "|"), + "trackedPaneTabCount": String(titles.count), + "trackedPaneSelectedTitle": selectedTitle, + ]) + } + + private func writeBonsplitTabDragUITestData(_ updates: [String: String]) { + guard let path = bonsplitTabDragUITestDataPath() else { return } + var payload = loadBonsplitTabDragUITestData(at: path) + for (key, value) in updates { + payload[key] = value + } + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + private func loadBonsplitTabDragUITestData(at path: String) -> [String: String] { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return object + } + private func isGotoSplitUITestRecordingEnabled() -> Bool { let env = ProcessInfo.processInfo.environment return env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" || env["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] == "1" @@ -7370,6 +7582,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.attach(to: window) } + func refreshTitlebarAccessory(for window: NSWindow) { + titlebarAccessoryController.refresh(for: window) + } + func applyWindowDecorations(to window: NSWindow) { windowDecorationsController.apply(to: window) } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d72a1e5d2..129cfe3eb 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -282,8 +282,17 @@ struct TitlebarLayerBackground: NSViewRepresentable { } } +extension Notification.Name { + static let cmuxSidebarVisibilityDidChange = Notification.Name("cmuxSidebarVisibilityDidChange") +} + final class SidebarState: ObservableObject { - @Published var isVisible: Bool + @Published var isVisible: Bool { + didSet { + guard oldValue != isVisible else { return } + NotificationCenter.default.post(name: .cmuxSidebarVisibilityDidChange, object: self) + } + } @Published var persistedWidth: CGFloat init(isVisible: Bool = true, persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) { @@ -1931,66 +1940,72 @@ struct ContentView: View { .frame(width: sidebarWidth) } - /// Space at top of content area for the titlebar. This must be at least the actual titlebar - /// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags. + /// Space at top of content area reserved for the custom workspace titlebar. @State private var titlebarPadding: CGFloat = 32 + private var effectiveTitlebarPadding: CGFloat { + showWorkspaceTitlebar ? titlebarPadding : 0 + } + private var terminalContent: some View { let mountedWorkspaceIdSet = Set(mountedWorkspaceIds) let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) } let selectedWorkspaceId = tabManager.selectedTabId let retiringWorkspaceId = self.retiringWorkspaceId - return ZStack { + return ZStack(alignment: .top) { ZStack { - ForEach(mountedWorkspaces) { tab in - let isSelectedWorkspace = selectedWorkspaceId == tab.id - let isRetiringWorkspace = retiringWorkspaceId == tab.id - let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id) - // Keep the retiring workspace visible during handoff, but never input-active. - // Allowing both selected+retiring workspaces to be input-active lets the - // old workspace steal first responder (notably with WKWebView), which can - // delay handoff completion and make browser returns feel laggy. - let isInputActive = isSelectedWorkspace - let isVisible = isSelectedWorkspace || isRetiringWorkspace - let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) - WorkspaceContentView( - workspace: tab, - isWorkspaceVisible: isVisible, - isWorkspaceInputActive: isInputActive, - workspacePortalPriority: portalPriority, - onThemeRefreshRequest: { reason, eventId, source, payloadHex in - scheduleTitlebarThemeRefreshFromWorkspace( - workspaceId: tab.id, - reason: reason, - backgroundEventId: eventId, - backgroundSource: source, - notificationPayloadHex: payloadHex - ) + ZStack { + ForEach(mountedWorkspaces) { tab in + let isSelectedWorkspace = selectedWorkspaceId == tab.id + let isRetiringWorkspace = retiringWorkspaceId == tab.id + let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id) + // Keep the retiring workspace visible during handoff, but never input-active. + // Allowing both selected+retiring workspaces to be input-active lets the + // old workspace steal first responder (notably with WKWebView), which can + // delay handoff completion and make browser returns feel laggy. + let isInputActive = isSelectedWorkspace + let isVisible = isSelectedWorkspace || isRetiringWorkspace + let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) + WorkspaceContentView( + workspace: tab, + isWorkspaceVisible: isVisible, + isWorkspaceInputActive: isInputActive, + workspacePortalPriority: portalPriority, + onThemeRefreshRequest: { reason, eventId, source, payloadHex in + scheduleTitlebarThemeRefreshFromWorkspace( + workspaceId: tab.id, + reason: reason, + backgroundEventId: eventId, + backgroundSource: source, + notificationPayloadHex: payloadHex + ) + } + ) + .opacity(isVisible ? 1 : 0) + .allowsHitTesting(isSelectedWorkspace) + .accessibilityHidden(!isVisible) + .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) + .task(id: shouldPrimeInBackground ? tab.id : nil) { + await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id) } - ) - .opacity(isVisible ? 1 : 0) - .allowsHitTesting(isSelectedWorkspace) - .accessibilityHidden(!isVisible) - .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) - .task(id: shouldPrimeInBackground ? tab.id : nil) { - await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id) } } + .opacity(sidebarSelectionState.selection == .tabs ? 1 : 0) + .allowsHitTesting(sidebarSelectionState.selection == .tabs) + .accessibilityHidden(sidebarSelectionState.selection != .tabs) + + NotificationsPage(selection: $sidebarSelectionState.selection) + .opacity(sidebarSelectionState.selection == .notifications ? 1 : 0) + .allowsHitTesting(sidebarSelectionState.selection == .notifications) + .accessibilityHidden(sidebarSelectionState.selection != .notifications) } - .opacity(sidebarSelectionState.selection == .tabs ? 1 : 0) - .allowsHitTesting(sidebarSelectionState.selection == .tabs) - .accessibilityHidden(sidebarSelectionState.selection != .tabs) + .padding(.top, effectiveTitlebarPadding) - NotificationsPage(selection: $sidebarSelectionState.selection) - .opacity(sidebarSelectionState.selection == .notifications ? 1 : 0) - .allowsHitTesting(sidebarSelectionState.selection == .notifications) - .accessibilityHidden(sidebarSelectionState.selection != .notifications) - } - .padding(.top, titlebarPadding) - .overlay(alignment: .top) { - // Titlebar overlay is only over terminal content, not the sidebar. - customTitlebar + if showWorkspaceTitlebar { + // Titlebar overlay is only over terminal content, not the sidebar. + customTitlebar + } } } @@ -2007,6 +2022,8 @@ struct ContentView: View { @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false + @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar @AppStorage("debugTitlebarLeadingExtra") private var debugTitlebarLeadingExtra: Double = 0 @State private var titlebarLeadingInset: CGFloat = 12 @@ -2623,7 +2640,7 @@ struct ContentView: View { removeSidebarResizerPointerMonitor() }) - view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in + view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity, showWorkspaceTitlebar] window in window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) window.titlebarAppearsTransparent = true // Do not make the entire background draggable; it interferes with drag gestures @@ -2646,10 +2663,11 @@ struct ContentView: View { } } - // Keep content below the titlebar so drags on Bonsplit's tab bar don't - // get interpreted as window drags. + // Reserve space for the custom titlebar only when it is enabled. When hidden, the + // top Bonsplit tab bar moves into this strip and its empty space gets an explicit + // drag handle overlay instead. let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height - let nextPadding = max(28, min(72, computedTitlebarHeight)) + let nextPadding = showWorkspaceTitlebar ? max(28, min(72, computedTitlebarHeight)) : 0 if abs(titlebarPadding - nextPadding) > 0.5 { DispatchQueue.main.async { titlebarPadding = nextPadding @@ -7166,11 +7184,18 @@ struct VerticalTabsSidebar: View { private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @AppStorage(SidebarWorkspaceDetailSettings.showNotificationMessageKey) private var sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage + @AppStorage("workspaceTitlebarVisible") + private var showWorkspaceTitlebar = true /// Space at top of sidebar for traffic light buttons private let trafficLightPadding: CGFloat = 28 + @State private var hiddenTitlebarTrafficLightPadding: CGFloat = 30 private let tabRowSpacing: CGFloat = 2 + private var effectiveTrafficLightPadding: CGFloat { + showWorkspaceTitlebar ? trafficLightPadding : hiddenTitlebarTrafficLightPadding + } + private var showsSidebarNotificationMessage: Bool { SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( showNotificationMessage: sidebarShowNotificationMessage, @@ -7185,7 +7210,7 @@ struct VerticalTabsSidebar: View { VStack(spacing: 0) { // Space for traffic lights / fullscreen controls Spacer() - .frame(height: trafficLightPadding) + .frame(height: effectiveTrafficLightPadding) LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in @@ -7218,7 +7243,8 @@ struct VerticalTabsSidebar: View { .equatable() } } - .padding(.vertical, 8) + .padding(.top, showWorkspaceTitlebar ? 8 : 4) + .padding(.bottom, 8) SidebarEmptyArea( rowSpacing: tabRowSpacing, @@ -7240,14 +7266,16 @@ struct VerticalTabsSidebar: View { .frame(width: 0, height: 0) ) .overlay(alignment: .top) { - SidebarTopScrim(height: trafficLightPadding + 20) + SidebarTopScrim(height: effectiveTrafficLightPadding + 20) .allowsHitTesting(false) } .overlay(alignment: .top) { - // Match native titlebar behavior in the sidebar top strip: - // drag-to-move and double-click action (zoom/minimize). - WindowDragHandleView() - .frame(height: trafficLightPadding) + if showWorkspaceTitlebar { + // Match native titlebar behavior in the sidebar top strip: + // drag-to-move and double-click action (zoom/minimize). + WindowDragHandleView() + .frame(height: trafficLightPadding) + } } .background(Color.clear) .modifier(ClearScrollBackground()) @@ -7264,6 +7292,16 @@ struct VerticalTabsSidebar: View { } .frame(width: 0, height: 0) ) + .background( + WindowTrafficLightMetricsReader { metrics in + guard !showWorkspaceTitlebar else { return } + let nextPadding = max(trafficLightPadding, min(64, metrics.topInset)) + if abs(hiddenTitlebarTrafficLightPadding - nextPadding) > 0.5 { + hiddenTitlebarTrafficLightPadding = nextPadding + } + } + .frame(width: 0, height: 0) + ) .onAppear { modifierKeyMonitor.start() draggedTabId = nil @@ -9976,6 +10014,7 @@ private struct TabItemView: View, Equatable { isHovering = hovering } .accessibilityElement(children: .combine) + .accessibilityIdentifier("sidebarWorkspace.\(tab.id.uuidString)") .accessibilityLabel(Text(accessibilityTitle)) .accessibilityHint(Text(accessibilityHintText)) .accessibilityAction(named: Text(moveUpActionText)) { diff --git a/Sources/TitlebarVisibilitySettings.swift b/Sources/TitlebarVisibilitySettings.swift new file mode 100644 index 000000000..e7c306f68 --- /dev/null +++ b/Sources/TitlebarVisibilitySettings.swift @@ -0,0 +1,44 @@ +import Foundation + +enum ChromeControlsVisibilityMode: String, CaseIterable, Identifiable { + case always + case onHover + + var id: String { rawValue } +} + +enum TitlebarControlsVisibilitySettings { + static let modeKey = "titlebarControlsVisibilityMode" + static let defaultMode: ChromeControlsVisibilityMode = .always + + static func mode(for rawValue: String?) -> ChromeControlsVisibilityMode { + guard let rawValue, let mode = ChromeControlsVisibilityMode(rawValue: rawValue) else { + return defaultMode + } + return mode + } +} + +enum PaneTabBarControlsVisibilitySettings { + static let modeKey = "paneTabBarControlsVisibilityMode" + static let defaultMode: ChromeControlsVisibilityMode = .always + + static func mode(for rawValue: String?) -> ChromeControlsVisibilityMode { + guard let rawValue, let mode = ChromeControlsVisibilityMode(rawValue: rawValue) else { + return defaultMode + } + return mode + } +} + +enum WorkspaceTitlebarSettings { + static let showTitlebarKey = "workspaceTitlebarVisible" + static let defaultShowTitlebar = true + + static func isVisible(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showTitlebarKey) == nil { + return defaultShowTitlebar + } + return defaults.bool(forKey: showTitlebarKey) + } +} diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 984df39c2..06edffc8e 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -117,6 +117,7 @@ struct TitlebarControlsStyleConfig { final class TitlebarControlsViewModel: ObservableObject { weak var notificationsAnchorView: NSView? + @Published var canRevealControls = true } struct NotificationsAnchorView: NSViewRepresentable { @@ -241,10 +242,13 @@ struct TitlebarControlsView: View { let onToggleNotifications: () -> Void let onNewTab: () -> Void @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue + @AppStorage(TitlebarControlsVisibilitySettings.modeKey) + private var visibilityModeRawValue = TitlebarControlsVisibilitySettings.defaultMode.rawValue @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @State private var shortcutRefreshTick = 0 + @State private var isHoveringControls = false @StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor() private let titlebarHintRightSafetyShift: CGFloat = 10 private let titlebarHintBaseXShift: CGFloat = -10 @@ -279,6 +283,20 @@ struct TitlebarControlsView: View { alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed } + private var visibilityMode: ChromeControlsVisibilityMode { + TitlebarControlsVisibilitySettings.mode(for: visibilityModeRawValue) + } + + private var shouldShowControls: Bool { + guard viewModel.canRevealControls else { return false } + switch visibilityMode { + case .always: + return true + case .onHover: + return isHoveringControls || shouldShowTitlebarShortcutHints + } + } + var body: some View { // Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings. // (The titlebar controls don't otherwise re-render on UserDefaults changes.) @@ -288,12 +306,19 @@ struct TitlebarControlsView: View { controlsGroup(config: config) .padding(.leading, 4) .padding(.trailing, titlebarHintTrailingInset) + .contentShape(Rectangle()) + .opacity(shouldShowControls ? 1 : 0) + .allowsHitTesting(viewModel.canRevealControls) + .animation(.easeInOut(duration: 0.14), value: shouldShowControls) .background( WindowAccessor { window in modifierKeyMonitor.setHostWindow(window) } .frame(width: 0, height: 0) ) + .onHover { hovering in + isHoveringControls = hovering + } .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in shortcutRefreshTick &+= 1 } @@ -701,6 +726,13 @@ func titlebarControlsShouldApplyLayout( || abs(previous.yOffset - next.yOffset) > tolerance } +func titlebarControlsShouldReserveAccessorySpace( + showWorkspaceTitlebar: Bool, + sidebarVisible: Bool +) -> Bool { + showWorkspaceTitlebar || sidebarVisible +} + final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate { private let hostingView: NonDraggableHostingView private let containerView = NSView() @@ -711,8 +743,10 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private var cachedFittingSize: NSSize? private var lastObservedViewSize: NSSize = .zero private var lastAppliedLayoutSnapshot: TitlebarControlsLayoutSnapshot? + private var shouldReserveAccessorySpace = true private let viewModel = TitlebarControlsViewModel() private var userDefaultsObserver: NSObjectProtocol? + private var sidebarVisibilityObserver: NSObjectProtocol? var popoverIsShownForTesting: Bool { notificationsPopover.isShown } init(notificationStore: TerminalNotificationStore) { @@ -749,9 +783,18 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont object: nil, queue: .main ) { [weak self] _ in + self?.refreshVisibility() self?.scheduleSizeUpdate(invalidateFittingSize: true) } + sidebarVisibilityObserver = NotificationCenter.default.addObserver( + forName: .cmuxSidebarVisibilityDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshVisibility() + } + scheduleSizeUpdate(invalidateFittingSize: true) } @@ -763,10 +806,14 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont if let userDefaultsObserver { NotificationCenter.default.removeObserver(userDefaultsObserver) } + if let sidebarVisibilityObserver { + NotificationCenter.default.removeObserver(sidebarVisibilityObserver) + } } override func viewDidAppear() { super.viewDidAppear() + refreshVisibility() scheduleSizeUpdate(invalidateFittingSize: true) } @@ -795,7 +842,33 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont } } + func refreshVisibility() { + let next = titlebarControlsShouldReserveAccessorySpace( + showWorkspaceTitlebar: WorkspaceTitlebarSettings.isVisible(), + sidebarVisible: AppDelegate.shared?.sidebarVisibility(for: view.window) ?? true + ) + viewModel.canRevealControls = next + if !next { + dismissNotificationsPopover() + } + guard next != shouldReserveAccessorySpace else { return } + shouldReserveAccessorySpace = next + scheduleSizeUpdate(invalidateFittingSize: true) + } + private func updateSize() { + guard shouldReserveAccessorySpace else { + lastAppliedLayoutSnapshot = nil + preferredContentSize = .zero + isHidden = true + view.isHidden = true + containerView.isHidden = true + hostingView.isHidden = true + containerView.frame = .zero + hostingView.frame = .zero + return + } + let contentSize: NSSize if fittingSizeNeedsRefresh || cachedFittingSize == nil { hostingView.invalidateIntrinsicContentSize() @@ -806,6 +879,10 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont contentSize = cachedFittingSize ?? .zero guard contentSize.width > 0, contentSize.height > 0 else { return } + isHidden = false + view.isHidden = false + containerView.isHidden = false + hostingView.isHidden = false let titlebarHeight = view.window.map { window in window.frame.height - window.contentLayoutRect.height } ?? contentSize.height @@ -1090,6 +1167,7 @@ private struct NotificationPopoverRow: View { } } +@MainActor final class UpdateTitlebarAccessoryController { private weak var updateViewModel: UpdateViewModel? private var didStart = false @@ -1122,6 +1200,11 @@ final class UpdateTitlebarAccessoryController { attachIfNeeded(to: window) } + func refresh(for window: NSWindow) { + attachIfNeeded(to: window) + controlsControllers.allObjects.first(where: { $0.view.window === window })?.refreshVisibility() + } + private func installObservers() { let center = NotificationCenter.default observers.append(center.addObserver( @@ -1175,7 +1258,6 @@ final class UpdateTitlebarAccessoryController { } private func attachIfNeeded(to window: NSWindow) { - guard !attachedWindows.contains(window) else { return } guard !isSettingsWindow(window) else { return } // Window identifiers are assigned by SwiftUI via WindowAccessor, which can run @@ -1198,6 +1280,8 @@ final class UpdateTitlebarAccessoryController { pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) + guard !attachedWindows.contains(window) else { return } + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == controlsIdentifier }) { let controls = TitlebarControlsAccessoryViewController( notificationStore: TerminalNotificationStore.shared @@ -1209,6 +1293,7 @@ final class UpdateTitlebarAccessoryController { } attachedWindows.add(window) + controlsControllers.allObjects.first(where: { $0.view.window === window })?.refreshVisibility() #if DEBUG let env = ProcessInfo.processInfo.environment @@ -1219,6 +1304,37 @@ final class UpdateTitlebarAccessoryController { #endif } + private func removeAccessoryIfPresent(from window: NSWindow) { + let matchingIndices = window.titlebarAccessoryViewControllers.indices.reversed().filter { index in + window.titlebarAccessoryViewControllers[index].view.identifier == controlsIdentifier + } + guard !matchingIndices.isEmpty || attachedWindows.contains(window) else { return } + + for index in matchingIndices { + let accessory = window.titlebarAccessoryViewControllers[index] + if let controls = accessory as? TitlebarControlsAccessoryViewController { + controls.dismissNotificationsPopover() + } + window.removeTitlebarAccessoryViewController(at: index) + } + + attachedWindows.remove(window) + pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) + window.contentView?.needsLayout = true + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.superview?.needsLayout = true + window.contentView?.superview?.layoutSubtreeIfNeeded() + window.invalidateShadow() + +#if DEBUG + let env = ProcessInfo.processInfo.environment + if env["CMUX_UI_TEST_MODE"] == "1" { + let ident = window.identifier?.rawValue ?? "" + UpdateLogStore.shared.append("removed titlebar accessories from window id=\(ident)") + } +#endif + } + private func isSettingsWindow(_ window: NSWindow) -> Bool { if window.identifier?.rawValue == "cmux.settings" { return true diff --git a/Sources/WindowAccessor.swift b/Sources/WindowAccessor.swift index a3d257a37..461694338 100644 --- a/Sources/WindowAccessor.swift +++ b/Sources/WindowAccessor.swift @@ -59,3 +59,100 @@ final class WindowObservingView: NSView { } } } + +struct WindowTrafficLightMetrics: Equatable { + let leadingInset: CGFloat + let topInset: CGFloat +} + +struct WindowTrafficLightMetricsReader: NSViewRepresentable { + let onMetrics: (WindowTrafficLightMetrics) -> Void + + func makeNSView(context: Context) -> WindowTrafficLightMetricsView { + let view = WindowTrafficLightMetricsView() + view.onMetrics = onMetrics + return view + } + + func updateNSView(_ nsView: WindowTrafficLightMetricsView, context: Context) { + nsView.onMetrics = onMetrics + nsView.publishMetricsIfPossible() + } +} + +final class WindowTrafficLightMetricsView: NSView { + var onMetrics: ((WindowTrafficLightMetrics) -> Void)? + + private weak var observedWindow: NSWindow? + private var observers: [NSObjectProtocol] = [] + private var lastPublishedMetrics: WindowTrafficLightMetrics? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window !== observedWindow { + reinstallObservers(for: window) + } + publishMetricsIfPossible() + } + + override func layout() { + super.layout() + publishMetricsIfPossible() + } + + deinit { + removeObservers() + } + + func publishMetricsIfPossible() { + DispatchQueue.main.async { [weak self] in + guard let self, + let window = self.window ?? self.observedWindow, + let contentView = window.contentView else { + return + } + + let buttonTypes: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + let frames = buttonTypes.compactMap { type -> CGRect? in + guard let button = window.standardWindowButton(type), !button.isHidden else { return nil } + return contentView.convert(button.bounds, from: button) + } + guard !frames.isEmpty else { return } + + let metrics = WindowTrafficLightMetrics( + leadingInset: (frames.map(\.maxX).max() ?? 0) + 14, + topInset: (frames.map { max(0, contentView.bounds.maxY - $0.minY) }.max() ?? 0) + 8 + ) + guard metrics != self.lastPublishedMetrics else { return } + self.lastPublishedMetrics = metrics + self.onMetrics?(metrics) + } + } + + private func reinstallObservers(for window: NSWindow?) { + removeObservers() + observedWindow = window + guard let window else { return } + + let center = NotificationCenter.default + let names: [Notification.Name] = [ + NSWindow.didResizeNotification, + NSWindow.didEndLiveResizeNotification, + NSWindow.didBecomeKeyNotification, + NSWindow.didBecomeMainNotification, + ] + observers = names.map { name in + center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in + self?.publishMetricsIfPossible() + } + } + } + + private func removeObservers() { + let center = NotificationCenter.default + for observer in observers { + center.removeObserver(observer) + } + observers.removeAll() + } +} diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 0b955943c..478c4cd1c 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -16,6 +16,8 @@ struct WorkspaceContentView: View { _ notificationPayloadHex: String? ) -> Void)? @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") + @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -52,7 +54,7 @@ struct WorkspaceContentView: View { } }() - BonsplitView(controller: workspace.bonsplitController) { tab, paneId in + let bonsplitView = BonsplitView(controller: workspace.bonsplitController) { tab, paneId in // Content for each tab in bonsplit let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) if let panel = workspace.panel(for: tab.id) { @@ -106,6 +108,14 @@ struct WorkspaceContentView: View { workspace.bonsplitController.focusPane(paneId) } } + Group { + if showWorkspaceTitlebar { + bonsplitView + } else { + bonsplitView + .ignoresSafeArea(.container, edges: .top) + } + } .internalOnlyTabDrag() // Split zoom swaps Bonsplit between the full split tree and a single pane view. // Recreate the Bonsplit subtree on zoom enter/exit so stale pre-zoom pane chrome diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 96703df93..ac363d510 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3040,6 +3040,17 @@ enum TelemetrySettings { static let enabledForCurrentLaunch = isEnabled() } +private extension ChromeControlsVisibilityMode { + var displayName: String { + switch self { + case .always: + return String(localized: "settings.app.titlebarControls.always", defaultValue: "Always Visible") + case .onHover: + return String(localized: "settings.app.titlebarControls.hover", defaultValue: "Show on Hover") + } + } +} + struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 @@ -3048,6 +3059,12 @@ struct SettingsView: View { @AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue + @AppStorage(TitlebarControlsVisibilitySettings.modeKey) + private var titlebarControlsVisibilityMode = TitlebarControlsVisibilitySettings.defaultMode.rawValue + @AppStorage(PaneTabBarControlsVisibilitySettings.modeKey) + private var paneTabBarControlsVisibilityMode = PaneTabBarControlsVisibilitySettings.defaultMode.rawValue + @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @@ -3121,6 +3138,71 @@ struct SettingsView: View { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement } + private var selectedTitlebarControlsVisibilityMode: ChromeControlsVisibilityMode { + TitlebarControlsVisibilitySettings.mode(for: titlebarControlsVisibilityMode) + } + + private var titlebarControlsVisibilitySelection: Binding { + Binding( + get: { selectedTitlebarControlsVisibilityMode.rawValue }, + set: { titlebarControlsVisibilityMode = TitlebarControlsVisibilitySettings.mode(for: $0).rawValue } + ) + } + + private var titlebarControlsVisibilitySubtitle: String { + switch selectedTitlebarControlsVisibilityMode { + case .always: + return String( + localized: "settings.app.titlebarControls.subtitleAlways", + defaultValue: "Keep the sidebar, notifications, and new workspace buttons visible." + ) + case .onHover: + return String( + localized: "settings.app.titlebarControls.subtitleHover", + defaultValue: "Hide titlebar buttons until the pointer reaches them." + ) + } + } + + private var selectedPaneTabBarControlsVisibilityMode: ChromeControlsVisibilityMode { + PaneTabBarControlsVisibilitySettings.mode(for: paneTabBarControlsVisibilityMode) + } + + private var paneTabBarControlsVisibilitySelection: Binding { + Binding( + get: { selectedPaneTabBarControlsVisibilityMode.rawValue }, + set: { paneTabBarControlsVisibilityMode = PaneTabBarControlsVisibilitySettings.mode(for: $0).rawValue } + ) + } + + private var paneTabBarControlsVisibilitySubtitle: String { + switch selectedPaneTabBarControlsVisibilityMode { + case .always: + return String( + localized: "settings.app.paneTabBarControls.subtitleAlways", + defaultValue: "Keep the pane tab bar's new tab and split buttons visible." + ) + case .onHover: + return String( + localized: "settings.app.paneTabBarControls.subtitleHover", + defaultValue: "Hide the pane tab bar's new tab and split buttons until you hover the bar." + ) + } + } + + private var workspaceTitlebarSubtitle: String { + if showWorkspaceTitlebar { + return String( + localized: "settings.app.showWorkspaceTitlebar.subtitleOn", + defaultValue: "Show the folder and active title above pane tabs." + ) + } + return String( + localized: "settings.app.showWorkspaceTitlebar.subtitleOff", + defaultValue: "Hide the folder/title strip and drag from empty space in the top pane tab bar." + ) + } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) } @@ -3475,6 +3557,43 @@ struct SettingsView: View { SettingsCardDivider() + SettingsPickerRow( + String(localized: "settings.app.titlebarControls", defaultValue: "Titlebar Controls"), + subtitle: titlebarControlsVisibilitySubtitle, + controlWidth: pickerColumnWidth, + selection: titlebarControlsVisibilitySelection + ) { + ForEach(ChromeControlsVisibilityMode.allCases) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } + + SettingsCardDivider() + + SettingsPickerRow( + String(localized: "settings.app.paneTabBarControls", defaultValue: "Pane Tab Bar Controls"), + subtitle: paneTabBarControlsVisibilitySubtitle, + controlWidth: pickerColumnWidth, + selection: paneTabBarControlsVisibilitySelection + ) { + ForEach(ChromeControlsVisibilityMode.allCases) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar"), + subtitle: workspaceTitlebarSubtitle + ) { + Toggle("", isOn: $showWorkspaceTitlebar) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsPickerRow( String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), subtitle: selectedWorkspacePlacement.description, @@ -4386,6 +4505,9 @@ struct SettingsView: View { } appearanceMode = AppearanceSettings.defaultMode.rawValue appIconMode = AppIconSettings.defaultMode.rawValue + titlebarControlsVisibilityMode = TitlebarControlsVisibilitySettings.defaultMode.rawValue + paneTabBarControlsVisibilityMode = PaneTabBarControlsVisibilitySettings.defaultMode.rawValue + showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar AppIconSettings.applyIcon(.automatic) socketControlMode = SocketControlSettings.defaultMode.rawValue claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 1225c111c..6751f251c 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -192,3 +192,24 @@ final class TitlebarControlsHoverPolicyTests: XCTestCase { XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.softButtons.config)) } } + +final class TitlebarControlsVisibilityPolicyTests: XCTestCase { + func testAccessorySpaceCollapsesOnlyWhenHiddenTitlebarAndHiddenSidebarCoincide() { + XCTAssertTrue(titlebarControlsShouldReserveAccessorySpace( + showWorkspaceTitlebar: true, + sidebarVisible: true + )) + XCTAssertTrue(titlebarControlsShouldReserveAccessorySpace( + showWorkspaceTitlebar: true, + sidebarVisible: false + )) + XCTAssertTrue(titlebarControlsShouldReserveAccessorySpace( + showWorkspaceTitlebar: false, + sidebarVisible: true + )) + XCTAssertFalse(titlebarControlsShouldReserveAccessorySpace( + showWorkspaceTitlebar: false, + sidebarVisible: false + )) + } +} diff --git a/cmuxUITests/BonsplitTabDragUITests.swift b/cmuxUITests/BonsplitTabDragUITests.swift new file mode 100644 index 000000000..0f7c9d869 --- /dev/null +++ b/cmuxUITests/BonsplitTabDragUITests.swift @@ -0,0 +1,482 @@ +import XCTest +import Foundation +import AppKit +import CoreGraphics + +final class BonsplitTabDragUITests: XCTestCase { + private let launchTimeout: TimeInterval = 20.0 + private let setupTimeout: TimeInterval = 25.0 + + override func setUp() { + super.setUp() + continueAfterFailure = false + + let cleanup = XCUIApplication() + cleanup.terminate() + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + + func testHiddenWorkspaceTitlebarKeepsTabReorderWorking() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for Bonsplit tab drag UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let betaTitle = ready["betaTitle"] ?? "UITest Beta" + let window = app.windows.element(boundBy: 0) + let alphaTab = app.buttons[alphaTitle] + let betaTab = app.buttons[betaTitle] + let dropIndicator = app.descendants(matching: .any).matching(identifier: "paneTabBar.dropIndicator").firstMatch + let initialOrder = "\(alphaTitle)|\(betaTitle)" + let reorderedOrder = "\(betaTitle)|\(alphaTitle)" + + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + XCTAssertTrue(betaTab.waitForExistence(timeout: 5.0), "Expected beta tab to exist") + XCTAssertTrue( + waitForJSONKey("trackedPaneTabTitles", equals: initialOrder, atPath: dataPath, timeout: 5.0) != nil, + "Expected initial tracked tab order to be \(initialOrder). data=\(loadJSON(atPath: dataPath) ?? [:])" + ) + XCTAssertLessThan(alphaTab.frame.minX, betaTab.frame.minX, "Expected beta tab to start to the right of alpha") + let windowFrameBeforeDrag = window.frame + + let start = CGPoint(x: betaTab.frame.midX, y: betaTab.frame.midY) + let destination = CGPoint(x: alphaTab.frame.midX - 14, y: alphaTab.frame.midY) + guard let dragSession = beginMouseDrag( + fromAccessibilityPoint: start, + holdDuration: 0.20 + ) else { + XCTFail("Expected raw mouse drag session to start") + return + } + continueMouseDrag( + dragSession, + toAccessibilityPoint: destination, + steps: 28, + dragDuration: 0.45 + ) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { dropIndicator.exists }, + "Expected dragging beta onto alpha to reveal the Bonsplit drop indicator." + ) + endMouseDrag(dragSession, atAccessibilityPoint: destination) + + XCTAssertTrue( + waitForJSONKey("trackedPaneTabTitles", equals: reorderedOrder, atPath: dataPath, timeout: 5.0) != nil, + "Expected tracked tab order to become \(reorderedOrder). data=\(loadJSON(atPath: dataPath) ?? [:])" + ) + XCTAssertTrue( + waitForCondition(timeout: 5.0) { betaTab.frame.minX < alphaTab.frame.minX }, + "Expected dragging beta onto alpha to reorder tab frames. alpha=\(alphaTab.frame) beta=\(betaTab.frame)" + ) + XCTAssertEqual(window.frame.origin.x, windowFrameBeforeDrag.origin.x, accuracy: 2.0, "Expected tab drag not to move the window horizontally") + XCTAssertEqual(window.frame.origin.y, windowFrameBeforeDrag.origin.y, accuracy: 2.0, "Expected tab drag not to move the window vertically") + } + + func testHiddenWorkspaceTitlebarPlacesPaneTabBarAtTopEdge() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for hidden titlebar top-gap UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let alphaTab = app.buttons[alphaTitle] + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + + let gapIfOriginIsBottomLeft = abs(window.frame.maxY - alphaTab.frame.maxY) + let gapIfOriginIsTopLeft = abs(alphaTab.frame.minY - window.frame.minY) + let topGap = min(gapIfOriginIsBottomLeft, gapIfOriginIsTopLeft) + XCTAssertLessThanOrEqual( + topGap, + 8, + "Expected the selected pane tab to reach the top edge when the workspace titlebar is hidden. window=\(window.frame) alphaTab=\(alphaTab.frame) gap.bottomLeft=\(gapIfOriginIsBottomLeft) gap.topLeft=\(gapIfOriginIsTopLeft)" + ) + } + + func testHiddenWorkspaceTitlebarKeepsSidebarRowsBelowTrafficLights() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for hidden titlebar sidebar inset UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let workspaceId = ready["workspaceId"] ?? "" + let workspaceRowIdentifier = "sidebarWorkspace.\(workspaceId)" + let workspaceRow = app.descendants(matching: .any).matching(identifier: workspaceRowIdentifier).firstMatch + XCTAssertTrue(workspaceRow.waitForExistence(timeout: 5.0), "Expected workspace row to exist") + + let topInset = distanceToTopEdge(of: workspaceRow, in: window) + XCTAssertGreaterThanOrEqual( + topInset, + 30, + "Expected hidden-titlebar sidebar rows to stay below the traffic lights. window=\(window.frame) workspaceRow=\(workspaceRow.frame) topInset=\(topInset)" + ) + } + + func testHiddenWorkspaceTitlebarTitlebarControlsRevealOnHover() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for hidden titlebar titlebar-controls hover UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let alphaTab = app.buttons[alphaTitle] + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + + let sidebar = app.descendants(matching: .any).matching(identifier: "Sidebar").firstMatch + XCTAssertTrue(sidebar.waitForExistence(timeout: 5.0), "Expected sidebar to exist") + + let toggleSidebarButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.toggleSidebar").firstMatch + let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch + let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch + + let paneLeadingGap = alphaTab.frame.minX - sidebar.frame.maxX + XCTAssertLessThan( + paneLeadingGap, + 28, + "Expected visible-sidebar hidden-titlebar mode to keep pane tabs tight to the sidebar edge while the traffic lights sit over the sidebar. window=\(window.frame) sidebar=\(sidebar.frame) alphaTab=\(alphaTab.frame) paneLeadingGap=\(paneLeadingGap)" + ) + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar controls to stay hidden away from the titlebar hover zone." + ) + + hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + toggleSidebarButton.exists && toggleSidebarButton.isHittable && + notificationsButton.exists && notificationsButton.isHittable && + newWorkspaceButton.exists && newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar controls to reveal when hovering the titlebar controls area." + ) + } + + func testHiddenWorkspaceTitlebarCollapsedSidebarKeepsControlsSuppressed() { + let (app, dataPath) = launchConfiguredApp(startWithHiddenSidebar: true) + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for collapsed-sidebar hidden-titlebar controls UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + XCTAssertEqual(ready["sidebarVisible"], "0", "Expected hidden-sidebar UI test setup to collapse the sidebar. data=\(ready)") + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let alphaTab = app.buttons[alphaTitle] + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + + let toggleSidebarButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.toggleSidebar").firstMatch + let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch + let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch + + hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + (!toggleSidebarButton.exists || !toggleSidebarButton.isHittable) && + (!notificationsButton.exists || !notificationsButton.isHittable) && + (!newWorkspaceButton.exists || !newWorkspaceButton.isHittable) + }, + "Expected collapsed-sidebar hidden-titlebar mode to keep titlebar controls suppressed. toggle=\(toggleSidebarButton.debugDescription) notifications=\(notificationsButton.debugDescription) new=\(newWorkspaceButton.debugDescription)" + ) + + let leadingInset = alphaTab.frame.minX - window.frame.minX + XCTAssertLessThan( + leadingInset, + 96, + "Expected pane tabs to stay near the leading edge when collapsed-sidebar hidden-titlebar mode removes the titlebar accessory lane. window=\(window.frame) alphaTab=\(alphaTab.frame) leadingInset=\(leadingInset)" + ) + } + + func testPaneTabBarControlsRevealWhenHoveringAnywhereOnPaneTabBar() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for Bonsplit controls hover UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let betaTitle = ready["betaTitle"] ?? "UITest Beta" + let alphaTab = app.buttons[alphaTitle] + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + let betaTab = app.buttons[betaTitle] + XCTAssertTrue(betaTab.waitForExistence(timeout: 5.0), "Expected beta tab to exist") + + let newTerminalButton = app.descendants(matching: .any).matching(identifier: "paneTabBarControl.newTerminal").firstMatch + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { !newTerminalButton.exists || !newTerminalButton.isHittable }, + "Expected pane tab bar controls to hide away from the pane tab bar. button=\(newTerminalButton.debugDescription)" + ) + + hover( + in: window, + at: CGPoint( + x: min(window.frame.maxX - 140, betaTab.frame.maxX + 80), + y: alphaTab.frame.midY + ) + ) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { newTerminalButton.exists && newTerminalButton.isHittable }, + "Expected pane tab bar controls to reveal when hovering inside empty pane-tab-bar space. window=\(window.frame) alphaTab=\(alphaTab.frame) betaTab=\(betaTab.frame) button=\(newTerminalButton.debugDescription)" + ) + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { !newTerminalButton.exists || !newTerminalButton.isHittable }, + "Expected pane tab bar controls to hide again after leaving the pane tab bar. button=\(newTerminalButton.debugDescription)" + ) + } + + private func launchConfiguredApp(startWithHiddenSidebar: Bool = false) -> (XCUIApplication, String) { + let app = XCUIApplication() + let dataPath = "/tmp/cmux-ui-test-bonsplit-tab-drag-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: dataPath) + + app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"] = dataPath + if startWithHiddenSidebar { + app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] = "1" + } + app.launchArguments += ["-workspaceTitlebarVisible", "NO"] + app.launchArguments += ["-titlebarControlsVisibilityMode", "onHover"] + app.launchArguments += ["-paneTabBarControlsVisibilityMode", "onHover"] + app.launch() + app.activate() + return (app, dataPath) + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + + private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if loadJSON(atPath: path) != nil { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return loadJSON(atPath: path) != nil + } + + private func waitForJSONKey(_ key: String, equals expected: String, atPath path: String, timeout: TimeInterval) -> [String: String]? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let data = loadJSON(atPath: path), data[key] == expected { + return data + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + if let data = loadJSON(atPath: path), data[key] == expected { + return data + } + return nil + } + + private func loadJSON(atPath path: String) -> [String: String]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return nil + } + return object + } + + private func waitForCondition(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return condition() + } + + private func hover(in window: XCUIElement, at point: CGPoint) { + let origin = window.coordinate(withNormalizedOffset: .zero) + origin.withOffset( + CGVector( + dx: point.x - window.frame.minX, + dy: point.y - window.frame.minY + ) + ).hover() + } + + private func distanceToTopEdge(of element: XCUIElement, in window: XCUIElement) -> CGFloat { + let gapIfOriginIsBottomLeft = abs(window.frame.maxY - element.frame.maxY) + let gapIfOriginIsTopLeft = abs(element.frame.minY - window.frame.minY) + return min(gapIfOriginIsBottomLeft, gapIfOriginIsTopLeft) + } + + private struct RawMouseDragSession { + let source: CGEventSource + } + + private func beginMouseDrag( + fromAccessibilityPoint start: CGPoint, + holdDuration: TimeInterval = 0.15 + ) -> RawMouseDragSession? { + let source = CGEventSource(stateID: .hidSystemState) + XCTAssertNotNil(source, "Expected CGEventSource for raw mouse drag") + guard let source else { return nil } + + let quartzStart = quartzPoint(fromAccessibilityPoint: start) + + postMouseEvent(type: .mouseMoved, at: quartzStart, source: source) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + postMouseEvent(type: .leftMouseDown, at: quartzStart, source: source) + RunLoop.current.run(until: Date().addingTimeInterval(holdDuration)) + return RawMouseDragSession(source: source) + } + + private func continueMouseDrag( + _ session: RawMouseDragSession, + toAccessibilityPoint end: CGPoint, + steps: Int = 20, + dragDuration: TimeInterval = 0.30 + ) { + let currentLocation = NSEvent.mouseLocation + let quartzEnd = quartzPoint(fromAccessibilityPoint: end) + let clampedSteps = max(2, steps) + for step in 1...clampedSteps { + let progress = CGFloat(step) / CGFloat(clampedSteps) + let point = CGPoint( + x: currentLocation.x + ((quartzEnd.x - currentLocation.x) * progress), + y: currentLocation.y + ((quartzEnd.y - currentLocation.y) * progress) + ) + postMouseEvent(type: .leftMouseDragged, at: point, source: session.source) + RunLoop.current.run(until: Date().addingTimeInterval(dragDuration / Double(clampedSteps))) + } + } + + private func endMouseDrag( + _ session: RawMouseDragSession, + atAccessibilityPoint end: CGPoint + ) { + let quartzEnd = quartzPoint(fromAccessibilityPoint: end) + postMouseEvent(type: .leftMouseUp, at: quartzEnd, source: session.source) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + + private func postMouseEvent( + type: CGEventType, + at point: CGPoint, + source: CGEventSource + ) { + guard let event = CGEvent( + mouseEventSource: source, + mouseType: type, + mouseCursorPosition: point, + mouseButton: .left + ) else { + XCTFail("Expected CGEvent for mouse type \(type.rawValue) at \(point)") + return + } + + event.setIntegerValueField(.mouseEventClickState, value: 1) + event.post(tap: .cghidEventTap) + } + + private func quartzPoint(fromAccessibilityPoint point: CGPoint) -> CGPoint { + let desktopBounds = NSScreen.screens.reduce(CGRect.null) { partialResult, screen in + partialResult.union(screen.frame) + } + XCTAssertFalse(desktopBounds.isNull, "Expected at least one screen when converting raw mouse coordinates") + guard !desktopBounds.isNull else { return point } + return CGPoint(x: point.x, y: desktopBounds.maxY - point.y) + } +} diff --git a/vendor/bonsplit b/vendor/bonsplit index fa452db18..f98559dec 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit fa452db181f361514087558a29204bda7e38218f +Subproject commit f98559deca1738b35d2f3841b220e2e6befecbb9