diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cce5d5bd..cdeba617 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -182,6 +182,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? weak var sidebarState: SidebarState? + weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? private var workspaceObserver: NSObjectProtocol? private var windowKeyObserver: NSObjectProtocol? @@ -1385,8 +1386,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowDecorationsController.apply(to: window) } - func toggleNotificationsPopover(animated: Bool = true) { - titlebarAccessoryController.toggleNotificationsPopover(animated: animated) + func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { + titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) } func jumpToLatestUnread() { @@ -1697,7 +1698,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Check Show Notifications shortcut if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showNotifications)) { - toggleNotificationsPopover(animated: false) + toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) return true } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6993ff69..d082268b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -174,6 +174,9 @@ struct ContentView: View { @State private var selectedTabIds: Set = [] @State private var lastSidebarSelectionIndex: Int? = nil @State private var titlebarText: String = "" + @State private var isFullScreen: Bool = false + @State private var observedWindow: NSWindow? + @StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel() private var sidebarView: some View { VerticalTabsSidebar( @@ -276,6 +279,7 @@ struct ContentView: View { @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true + @AppStorage("debugTitlebarLeadingExtra") private var debugTitlebarLeadingExtra: Double = 0 @State private var titlebarLeadingInset: CGFloat = 12 private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } @@ -294,6 +298,21 @@ struct ContentView: View { Color(nsColor: .separatorColor).opacity(colorScheme == .light ? 0.68 : 0.34) } + private var fullscreenControls: some View { + TitlebarControlsView( + notificationStore: TerminalNotificationStore.shared, + viewModel: fullscreenControlsViewModel, + onToggleSidebar: { AppDelegate.shared?.sidebarState?.toggle() }, + onToggleNotifications: { [fullscreenControlsViewModel] in + AppDelegate.shared?.toggleNotificationsPopover( + animated: true, + anchorView: fullscreenControlsViewModel.notificationsAnchorView + ) + }, + onNewTab: { tabManager.addTab() } + ) + } + private var customTitlebar: some View { ZStack { // Enable window dragging from the titlebar strip without making the entire content @@ -303,6 +322,10 @@ struct ContentView: View { TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) HStack(spacing: 8) { + if isFullScreen && !sidebarState.isVisible { + fullscreenControls + } + // Draggable folder icon + focused command name if let directory = focusedDirectory { DraggableFolderIcon(directory: directory) @@ -318,7 +341,7 @@ struct ContentView: View { } .frame(height: 28) .padding(.top, 2) - .padding(.leading, sidebarState.isVisible ? 12 : titlebarLeadingInset) + .padding(.leading, (isFullScreen && !sidebarState.isVisible) ? 8 : (sidebarState.isVisible ? 12 : titlebarLeadingInset + CGFloat(debugTitlebarLeadingExtra))) .padding(.trailing, 8) } .frame(height: titlebarPadding) @@ -386,6 +409,13 @@ struct ContentView: View { } } } + .overlay(alignment: .topLeading) { + if isFullScreen && sidebarState.isVisible { + fullscreenControls + .padding(.leading, 10) + .padding(.top, 4) + } + } .frame(minWidth: 800, minHeight: 600) .background(Color.clear) .onAppear { @@ -441,6 +471,20 @@ struct ContentView: View { } .onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { notification in + guard let window = notification.object as? NSWindow, + window === observedWindow else { return } + isFullScreen = true + setTitlebarControlsHidden(true, in: window) + AppDelegate.shared?.fullscreenControlsViewModel = fullscreenControlsViewModel + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { notification in + guard let window = notification.object as? NSWindow, + window === observedWindow else { return } + isFullScreen = false + setTitlebarControlsHidden(false, in: window) + AppDelegate.shared?.fullscreenControlsViewModel = nil } .ignoresSafeArea() .background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in @@ -451,6 +495,14 @@ struct ContentView: View { window.isMovableByWindowBackground = false window.styleMask.insert(.fullSizeContentView) + // Track this window for fullscreen notifications + if observedWindow !== window { + DispatchQueue.main.async { + observedWindow = window + isFullScreen = window.styleMask.contains(.fullScreen) + } + } + // Keep content below the titlebar so drags on Bonsplit's tab bar don't // get interpreted as window drags. let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height @@ -512,6 +564,16 @@ struct ContentView: View { let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) WindowGlassEffect.updateTint(to: window, color: tintColor) } + + private func setTitlebarControlsHidden(_ hidden: Bool, in window: NSWindow) { + let controlsId = NSUserInterfaceItemIdentifier("cmux.titlebarControls") + for accessory in window.titlebarAccessoryViewControllers { + if accessory.view.identifier == controlsId { + accessory.isHidden = hidden + accessory.view.alphaValue = hidden ? 0 : 1 + } + } + } } struct VerticalTabsSidebar: View { @@ -534,7 +596,7 @@ struct VerticalTabsSidebar: View { GeometryReader { proxy in ScrollView { VStack(spacing: 0) { - // Space for traffic lights + // Space for traffic lights / fullscreen controls Spacer() .frame(height: trafficLightPadding) @@ -2288,7 +2350,7 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable { where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { leading += accessory.view.frame.width } - leading += 16 + leading += 0 if leading != inset { inset = leading } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index b6e004ef..5c96e1be 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -119,7 +119,7 @@ final class TitlebarControlsViewModel: ObservableObject { weak var notificationsAnchorView: NSView? } -private struct NotificationsAnchorView: NSViewRepresentable { +struct NotificationsAnchorView: NSViewRepresentable { let onResolve: (NSView) -> Void func makeNSView(context: Context) -> NSView { @@ -134,7 +134,7 @@ private struct NotificationsAnchorView: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) {} } -private final class AnchorNSView: NSView { +final class AnchorNSView: NSView { var onLayout: (() -> Void)? override func layout() { @@ -193,7 +193,7 @@ struct ShortcutHintHorizontalPlanner { } } -private struct TitlebarControlButton: View { +struct TitlebarControlButton: View { let config: TitlebarControlsStyleConfig let action: () -> Void @ViewBuilder let content: () -> Content @@ -221,7 +221,7 @@ private struct TitlebarControlButton: View { } } -private struct TitlebarControlsView: View { +struct TitlebarControlsView: View { @ObservedObject var notificationStore: TerminalNotificationStore @ObservedObject var viewModel: TitlebarControlsViewModel let onToggleSidebar: () -> Void @@ -666,7 +666,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height) } - func toggleNotificationsPopover(animated: Bool = true) { + func toggleNotificationsPopover(animated: Bool = true, externalAnchor: NSView? = nil) { if notificationsPopover.isShown { notificationsPopover.performClose(nil) return @@ -684,7 +684,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont hostingController.view.layer?.backgroundColor = .clear notificationsPopover.contentViewController = hostingController - guard let window = view.window ?? hostingView.window ?? NSApp.keyWindow, + guard let window = externalAnchor?.window ?? view.window ?? hostingView.window ?? NSApp.keyWindow, let contentView = window.contentView else { return } @@ -692,7 +692,18 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont // Force layout to ensure geometry is current. contentView.layoutSubtreeIfNeeded() - if let anchorView = viewModel.notificationsAnchorView, anchorView.window != nil { + // Use external anchor (e.g. fullscreen sidebar controls) if provided. + if let externalAnchor, externalAnchor.window != nil { + externalAnchor.superview?.layoutSubtreeIfNeeded() + let anchorRect = externalAnchor.convert(externalAnchor.bounds, to: contentView) + if !anchorRect.isEmpty { + notificationsPopover.animates = animated + notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY) + return + } + } + + if let anchorView = viewModel.notificationsAnchorView, anchorView.window != nil, !isHidden { anchorView.superview?.layoutSubtreeIfNeeded() let anchorRect = anchorView.convert(anchorView.bounds, to: contentView) if !anchorRect.isEmpty { @@ -1034,10 +1045,21 @@ final class UpdateTitlebarAccessoryController { return controllers.first } - func toggleNotificationsPopover(animated: Bool = true) { + func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { let controllers = controlsControllers.allObjects guard !controllers.isEmpty else { return } + // If an external anchor is provided (e.g. fullscreen sidebar controls), + // use it for popover positioning instead of the hidden titlebar accessory. + if let anchorView, anchorView.window != nil { + let target = preferredNotificationsController(from: controllers, preferShownPopover: true) + for controller in controllers where controller !== target { + controller.dismissNotificationsPopover() + } + target?.toggleNotificationsPopover(animated: animated, externalAnchor: anchorView) + return + } + let target = preferredNotificationsController(from: controllers, preferShownPopover: true) for controller in controllers { if controller !== target { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0eba58c4..38af58fb 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1195,6 +1195,7 @@ private struct DebugWindowControlsView: View { @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 var body: some View { ScrollView { @@ -1261,6 +1262,23 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } + GroupBox("Titlebar Spacing") { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text("Leading extra") + Slider(value: $titlebarLeadingExtra, in: 0...40) + Text(String(format: "%.0f", titlebarLeadingExtra)) + .font(.caption) + .monospacedDigit() + .frame(width: 30, alignment: .trailing) + } + Button("Reset (0)") { + titlebarLeadingExtra = 0 + } + } + .padding(.top, 2) + } + GroupBox("Copy") { VStack(alignment: .leading, spacing: 8) { Button("Copy All Debug Config") {