diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 812aef3b..b10c4e69 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -43635,6 +43635,57 @@ } } }, + "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 workspace title bar and show sidebar or pane actions only on hover." + } + }, + "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.showPullRequests": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c0413ab3..5b0c9cb2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7663,6 +7663,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.dismissNotificationsPopoverIfShown() } + func isNotificationsPopoverShown() -> Bool { + titlebarAccessoryController.isNotificationsPopoverShown() + } + func jumpToLatestUnread() { guard let notificationStore else { return } #if DEBUG diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7d966328..b7d3c90d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1951,6 +1951,12 @@ struct ContentView: View { /// 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. @State private var titlebarPadding: CGFloat = 32 + @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar + + private var effectiveTitlebarPadding: CGFloat { + showWorkspaceTitlebar ? titlebarPadding : 0 + } private var terminalContent: some View { let mountedWorkspaceIdSet = Set(mountedWorkspaceIds) @@ -2004,10 +2010,12 @@ struct ContentView: View { .allowsHitTesting(sidebarSelectionState.selection == .notifications) .accessibilityHidden(sidebarSelectionState.selection != .notifications) } - .padding(.top, titlebarPadding) + .padding(.top, effectiveTitlebarPadding) .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 + } } } @@ -2224,7 +2232,7 @@ struct ContentView: View { contentAndSidebarLayout .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .topLeading) { - if isFullScreen && sidebarState.isVisible { + if isFullScreen && sidebarState.isVisible && showWorkspaceTitlebar { fullscreenControls .padding(.leading, 10) .padding(.top, 4) @@ -7765,10 +7773,13 @@ struct VerticalTabsSidebar: View { private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @AppStorage(SidebarWorkspaceDetailSettings.showNotificationMessageKey) private var sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage + @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar /// Space at top of sidebar for traffic light buttons private let trafficLightPadding: CGFloat = 28 private let tabRowSpacing: CGFloat = 2 + private let hiddenTitlebarControlsLeadingInset: CGFloat = 72 private var showsSidebarNotificationMessage: Bool { SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( @@ -7856,6 +7867,13 @@ struct VerticalTabsSidebar: View { WindowDragHandleView() .frame(height: trafficLightPadding) } + .overlay(alignment: .topLeading) { + if !showWorkspaceTitlebar { + HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) + .padding(.leading, hiddenTitlebarControlsLeadingInset) + .padding(.top, 2) + } + } .background(Color.clear) .modifier(ClearScrollBackground()) } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 984df39c..cd18fb56 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -119,6 +119,22 @@ final class TitlebarControlsViewModel: ObservableObject { weak var notificationsAnchorView: NSView? } +extension Notification.Name { + static let cmuxNotificationsPopoverVisibilityDidChange = Notification.Name("cmux.notificationsPopoverVisibilityDidChange") +} + +private enum NotificationsPopoverVisibilityUserInfoKey { + static let isShown = "isShown" +} + +private func postNotificationsPopoverVisibilityDidChange(isShown: Bool) { + NotificationCenter.default.post( + name: .cmuxNotificationsPopoverVisibilityDidChange, + object: nil, + userInfo: [NotificationsPopoverVisibilityUserInfoKey.isShown: isShown] + ) +} + struct NotificationsAnchorView: NSViewRepresentable { let onResolve: (NSView) -> Void @@ -508,6 +524,54 @@ struct TitlebarControlsView: View { } } +struct HiddenTitlebarSidebarControlsView: View { + @ObservedObject var notificationStore: TerminalNotificationStore + @StateObject private var viewModel = TitlebarControlsViewModel() + @State private var isHoveringControls = false + @State private var isNotificationsPopoverShown = false + + private let hostWidth: CGFloat = 124 + private let hostHeight: CGFloat = 28 + + private var shouldShowControls: Bool { + isHoveringControls || isNotificationsPopoverShown + } + + var body: some View { + ZStack(alignment: .leading) { + Color.clear + .frame(width: hostWidth, height: hostHeight) + + TitlebarControlsView( + notificationStore: notificationStore, + viewModel: viewModel, + onToggleSidebar: { _ = AppDelegate.shared?.sidebarState?.toggle() }, + onToggleNotifications: { [viewModel] in + AppDelegate.shared?.toggleNotificationsPopover( + animated: true, + anchorView: viewModel.notificationsAnchorView + ) + }, + onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() } + ) + .opacity(shouldShowControls ? 1 : 0) + .allowsHitTesting(shouldShowControls) + .animation(.easeInOut(duration: 0.12), value: shouldShowControls) + } + .frame(width: hostWidth, height: hostHeight, alignment: .leading) + .contentShape(Rectangle()) + .onHover { hovering in + isHoveringControls = hovering + } + .onAppear { + isNotificationsPopoverShown = AppDelegate.shared?.isNotificationsPopoverShown() ?? false + } + .onReceive(NotificationCenter.default.publisher(for: .cmuxNotificationsPopoverVisibilityDidChange)) { notification in + isNotificationsPopoverShown = (notification.userInfo?[NotificationsPopoverVisibilityUserInfoKey.isShown] as? Bool) ?? false + } + } +} + @MainActor private final class TitlebarShortcutHintModifierMonitor: ObservableObject { @Published private(set) var isModifierPressed = false @@ -714,6 +778,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private let viewModel = TitlebarControlsViewModel() private var userDefaultsObserver: NSObjectProtocol? var popoverIsShownForTesting: Bool { notificationsPopover.isShown } + private var showWorkspaceTitlebar: Bool { WorkspaceTitlebarSettings.isVisible() } init(notificationStore: TerminalNotificationStore) { self.notificationStore = notificationStore @@ -749,9 +814,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont object: nil, queue: .main ) { [weak self] _ in + self?.applyWorkspaceTitlebarVisibility() self?.scheduleSizeUpdate(invalidateFittingSize: true) } + applyWorkspaceTitlebarVisibility() scheduleSizeUpdate(invalidateFittingSize: true) } @@ -796,6 +863,8 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont } private func updateSize() { + applyWorkspaceTitlebarVisibility() + guard showWorkspaceTitlebar else { return } let contentSize: NSSize if fittingSizeNeedsRefresh || cachedFittingSize == nil { hostingView.invalidateIntrinsicContentSize() @@ -828,6 +897,16 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height) } + private func applyWorkspaceTitlebarVisibility() { + let shouldShow = showWorkspaceTitlebar + view.isHidden = !shouldShow + if !shouldShow { + preferredContentSize = .zero + containerView.frame = .zero + hostingView.frame = .zero + } + } + func toggleNotificationsPopover(animated: Bool = true, externalAnchor: NSView? = nil) { if notificationsPopover.isShown { notificationsPopover.performClose(nil) @@ -861,6 +940,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont if !anchorRect.isEmpty { notificationsPopover.animates = animated notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY) + postNotificationsPopoverVisibilityDidChange(isShown: true) return } } @@ -871,6 +951,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont if !anchorRect.isEmpty { notificationsPopover.animates = animated notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY) + postNotificationsPopoverVisibilityDidChange(isShown: true) return } } @@ -880,6 +961,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont let anchorRect = NSRect(x: 12, y: bounds.maxY - 8, width: 1, height: 1) notificationsPopover.animates = animated notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY) + postNotificationsPopoverVisibilityDidChange(isShown: true) } func dismissNotificationsPopover() { @@ -902,6 +984,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont func popoverDidClose(_ notification: Notification) { // Clear the content view controller to stop SwiftUI observers when popover is hidden notificationsPopover.contentViewController = nil + postNotificationsPopoverVisibilityDidChange(isShown: false) } } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 0b955943..b352a51a 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) { @@ -147,6 +149,15 @@ struct WorkspaceContentView: View { notificationPayloadHex: payloadHex ) } + + Group { + if showWorkspaceTitlebar { + bonsplitView + } else { + bonsplitView + .ignoresSafeArea(.container, edges: .top) + } + } } private func syncBonsplitNotificationBadges() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 20739849..3da2656d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4,6 +4,18 @@ import Darwin import Bonsplit import UniformTypeIdentifiers +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) + } +} + @main struct cmuxApp: App { @StateObject private var tabManager: TabManager @@ -3085,6 +3097,8 @@ 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(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @@ -3167,6 +3181,19 @@ struct SettingsView: View { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement } + 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 workspace title bar and show sidebar or pane actions only on hover." + ) + } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) } @@ -3559,6 +3586,20 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar"), + subtitle: workspaceTitlebarSubtitle + ) { + Toggle("", isOn: $showWorkspaceTitlebar) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar") + ) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") @@ -4623,6 +4664,7 @@ struct SettingsView: View { ShortcutHintDebugSettings.resetVisibilityDefaults() alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage diff --git a/vendor/bonsplit b/vendor/bonsplit index 73c1ef2d..a5598131 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826 +Subproject commit a55981319828bd832981c9be2275d199c266da41