diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9d04fe352..5ae023a33 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11,20 +11,37 @@ import Darwin final class MainWindowHostingView: NSHostingView { private let zeroSafeAreaLayoutGuide = NSLayoutGuide() + private let usesSystemSafeArea: Bool - override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } - override var safeAreaRect: NSRect { bounds } - override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + override var safeAreaInsets: NSEdgeInsets { + usesSystemSafeArea ? super.safeAreaInsets : NSEdgeInsetsZero + } + override var safeAreaRect: NSRect { + usesSystemSafeArea ? super.safeAreaRect : bounds + } + override var safeAreaLayoutGuide: NSLayoutGuide { + usesSystemSafeArea ? super.safeAreaLayoutGuide : zeroSafeAreaLayoutGuide + } required init(rootView: Content) { + if #available(macOS 26.0, *) { + // On macOS 26, use system safe area so: + // - Sidebar (.ignoresSafeArea) extends under the glass titlebar + // - Terminal content respects the titlebar and stays below it + self.usesSystemSafeArea = true + } else { + self.usesSystemSafeArea = false + } 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), - ]) + if !usesSystemSafeArea { + 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) @@ -2581,6 +2598,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent syncMenuBarExtraVisibility() updateController.startUpdaterIfNeeded() } + // Start the titlebar accessory controller on all versions so the + // notifications popover infrastructure is available. On macOS 26 + // the visual titlebar items come from SwiftUI .toolbar, but the + // popover is still managed by the accessory controller. titlebarAccessoryController.start() windowDecorationsController.start() installMainWindowKeyObserver() @@ -7084,10 +7105,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.collectionBehavior.insert(.fullScreenDisallowsTiling) } window.title = "" - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true + if #available(macOS 26.0, *) { + // On macOS 26+, let the system render the native glass titlebar + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = false + } else { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + } window.isMovableByWindowBackground = false - window.isMovable = false + if #available(macOS 26.0, *) { + window.isMovable = true + } else { + window.isMovable = false + } let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) if let restoredFrame { window.setFrame(restoredFrame, display: false) @@ -10119,6 +10150,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif func attachUpdateAccessory(to window: NSWindow) { + if #available(macOS 26.0, *) { + // On macOS 26, toolbar buttons are native SwiftUI .toolbar items + // in the NavigationSplitView. Skip attaching the old titlebar + // accessory views, but the controller is already started (for + // notifications popover support). + return + } titlebarAccessoryController.start() titlebarAccessoryController.attach(to: window) } @@ -10127,7 +10165,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowDecorationsController.apply(to: window) } + /// Notification posted on macOS 26 to toggle the SwiftUI notifications popover. + static let toggleNotificationsPopoverNotification = Notification.Name("cmux.toggleNotificationsPopover") + func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { + if #available(macOS 26.0, *) { + NotificationCenter.default.post(name: Self.toggleNotificationsPopoverNotification, object: nil) + return + } titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2e7a2d99a..6315601dd 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1823,6 +1823,7 @@ struct ContentView: View { @State private var isResizerBandActive = false @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? + @State private var isNotificationsPopoverPresented = false @State private var isCommandPalettePresented = false @State private var commandPaletteQuery: String = "" @State private var commandPaletteMode: CommandPaletteMode = .commands @@ -2590,7 +2591,7 @@ struct ContentView: View { } } - private var sidebarView: some View { + private var sidebarContent: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, onSendFeedback: presentFeedbackComposer, @@ -2598,8 +2599,12 @@ struct ContentView: View { selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) - .frame(width: sidebarWidth) - .frame(maxHeight: .infinity, alignment: .topLeading) + } + + private var sidebarView: some View { + sidebarContent + .frame(width: sidebarWidth) + .frame(maxHeight: .infinity, alignment: .topLeading) } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -2676,9 +2681,17 @@ struct ContentView: View { .allowsHitTesting(sidebarSelectionState.selection == .notifications) .accessibilityHidden(sidebarSelectionState.selection != .notifications) } - .padding(.top, effectiveTitlebarPadding) + .padding(.top, { + if #available(macOS 26.0, *) { + return 0 // Native glass titlebar handles spacing via safe area + } + return effectiveTitlebarPadding + }()) .overlay(alignment: .top) { - if !isMinimalMode { + if #available(macOS 26.0, *) { + // On macOS 26, native glass titlebar + SwiftUI .toolbar handles controls + EmptyView() + } else if !isMinimalMode { // Titlebar overlay is only over terminal content, not the sidebar. customTitlebar } @@ -2869,6 +2882,124 @@ struct ContentView: View { } private var contentAndSidebarLayout: AnyView { + // On macOS 26, use NavigationSplitView so the system recognizes + // the sidebar column and applies native Liquid Glass treatment. + if #available(macOS 26.0, *) { + return AnyView( + NavigationSplitView(columnVisibility: Binding( + get: { sidebarState.isVisible ? .all : .detailOnly }, + set: { newValue in + let shouldShow = (newValue != .detailOnly) + if shouldShow != sidebarState.isVisible { + _ = sidebarState.toggle() + } + } + )) { + sidebarContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + GeometryReader { geo in + Color.clear.onChange(of: geo.size.width) { newWidth in + if abs(newWidth - sidebarWidth) > 1 { + sidebarWidth = newWidth + sidebarState.persistedWidth = newWidth + } + } + } + ) + .navigationSplitViewColumnWidth(min: 120, ideal: sidebarWidth, max: 400) + } detail: { + terminalContentWithSidebarDropOverlay + .padding(8) + } + .navigationSplitViewStyle(.prominentDetail) + .background(SplitViewDividerHider()) + .toolbar(removing: .sidebarToggle) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + ControlGroup { + Button { + tabManager.newSurface() + } label: { + Image(systemName: "terminal") + } + .accessibilityIdentifier("toolbar.newTerminal") + .accessibilityLabel(String(localized: "toolbar.newTerminal.label", defaultValue: "New Terminal")) + + Button { + _ = AppDelegate.shared?.openBrowserAndFocusAddressBar() + } label: { + Image(systemName: "globe") + } + .accessibilityIdentifier("toolbar.newBrowser") + .accessibilityLabel(String(localized: "toolbar.newBrowser.label", defaultValue: "New Browser")) + + Button { + tabManager.createSplit(direction: .right) + } label: { + Image(systemName: "square.split.2x1") + } + .accessibilityIdentifier("toolbar.splitRight") + .accessibilityLabel(String(localized: "toolbar.splitRight.label", defaultValue: "Split Right")) + + Button { + tabManager.createSplit(direction: .down) + } label: { + Image(systemName: "square.split.1x2") + } + .accessibilityIdentifier("toolbar.splitDown") + .accessibilityLabel(String(localized: "toolbar.splitDown.label", defaultValue: "Split Down")) + } + + Button { + if #available(macOS 26.0, *) { + isNotificationsPopoverPresented.toggle() + } else { + _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) + } + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell") + if notificationStore.unreadCount > 0 { + Text("\(min(notificationStore.unreadCount, 99))") + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 14, height: 14) + .background(Circle().fill(Color.red)) + .offset(x: 5, y: -5) + } + } + } + .buttonStyle(.accessoryBarAction) + .accessibilityIdentifier("toolbar.notifications") + .accessibilityLabel(String(localized: "toolbar.notifications.label", defaultValue: "Notifications")) + .popover(isPresented: $isNotificationsPopoverPresented) { + NotificationsPopoverView( + notificationStore: notificationStore, + onDismiss: { isNotificationsPopoverPresented = false } + ) + } + .onReceive(NotificationCenter.default.publisher(for: AppDelegate.toggleNotificationsPopoverNotification)) { _ in + isNotificationsPopoverPresented.toggle() + } + + Button { + if let appDelegate = AppDelegate.shared { + if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "toolbar.newTab") == nil { + appDelegate.openNewMainWindow(nil) + } + } + } label: { + Image(systemName: "plus") + } + .buttonStyle(.accessoryBarAction) + .accessibilityIdentifier("toolbar.newTab") + .accessibilityLabel(String(localized: "toolbar.newTab.label", defaultValue: "New Tab")) + } + } + ) + } + let layout: AnyView // When matching terminal background, use HStack so both sidebar and terminal // sit directly on the window background with no intermediate layers. @@ -3429,13 +3560,24 @@ struct ContentView: View { view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) - window.titlebarAppearsTransparent = true - // Keep window immovable; the sidebar's WindowDragHandleView handles - // drag-to-move via performDrag with temporary movable override. - // isMovableByWindowBackground=true breaks tab reordering, and - // isMovable=true blocks clicks on sidebar buttons in minimal mode. - window.isMovableByWindowBackground = false - window.isMovable = false + if #available(macOS 26.0, *) { + window.titlebarAppearsTransparent = false + window.titlebarSeparatorStyle = .none + } else { + window.titlebarAppearsTransparent = true + } + if #available(macOS 26.0, *) { + // On macOS 26, the system titlebar handles drag natively. + window.isMovable = true + window.isMovableByWindowBackground = false + } else { + // Keep window immovable; the sidebar's WindowDragHandleView handles + // drag-to-move via performDrag with temporary movable override. + // isMovableByWindowBackground=true breaks tab reordering, and + // isMovable=true blocks clicks on sidebar buttons in minimal mode. + window.isMovableByWindowBackground = false + window.isMovable = false + } window.styleMask.insert(.fullSizeContentView) // Track this window for fullscreen notifications @@ -3467,37 +3609,41 @@ struct ContentView: View { // User settings decide whether window glass is active. The native Tahoe // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen // inside WindowGlassEffect.apply. + // On macOS 26+, the system handles glass compositing natively. + // Do NOT manually insert NSGlassEffectView -- it fights the system. let currentThemeBackground = GhosttyBackgroundTheme.currentColor() - let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( - sidebarBlendMode: sidebarBlendMode, - bgGlassEnabled: bgGlassEnabled, - glassEffectAvailable: WindowGlassEffect.isAvailable - ) - let shouldForceTransparentHosting = - shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 - - if shouldForceTransparentHosting { - window.isOpaque = false - // Keep the window clear whenever translucency is active. Relying only on - // terminal focus-driven updates can leave stale opaque window fills. - window.backgroundColor = NSColor.white.withAlphaComponent(0.001) - // Configure contentView hierarchy for transparency. - if let contentView = window.contentView { - makeViewHierarchyTransparent(contentView) - } - } else { - // Browser-focused workspaces may not have an active terminal panel to refresh - // the NSWindow background. Keep opaque theme changes applied here as well. + if #available(macOS 26.0, *) { + // On macOS 26, NavigationSplitView handles glass compositing. + // Keep standard window background for the terminal area. window.backgroundColor = currentThemeBackground window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 - } - - if shouldApplyWindowGlass { - // Apply liquid glass effect to the window with tint from settings - let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowGlassEffect.apply(to: window, tintColor: tintColor) - } else { WindowGlassEffect.remove(from: window) + } else { + let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( + sidebarBlendMode: sidebarBlendMode, + bgGlassEnabled: bgGlassEnabled, + glassEffectAvailable: WindowGlassEffect.isAvailable + ) + let shouldForceTransparentHosting = + shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 + + if shouldForceTransparentHosting { + window.isOpaque = false + window.backgroundColor = NSColor.white.withAlphaComponent(0.001) + if let contentView = window.contentView { + makeViewHierarchyTransparent(contentView) + } + } else { + window.backgroundColor = currentThemeBackground + window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 + } + + if shouldApplyWindowGlass { + let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) + WindowGlassEffect.apply(to: window, tintColor: tintColor) + } else { + WindowGlassEffect.remove(from: window) + } } AppDelegate.shared?.attachUpdateAccessory(to: window) AppDelegate.shared?.applyWindowDecorations(to: window) @@ -10029,10 +10175,6 @@ struct VerticalTabsSidebar: View { } .frame(width: 0, height: 0) ) - .overlay(alignment: .top) { - SidebarTopScrim(height: trafficLightPadding + 20) - .allowsHitTesting(false) - } .overlay(alignment: .top) { // Match native titlebar behavior in the sidebar top strip: // drag-to-move and double-click action (zoom/minimize). @@ -10041,7 +10183,7 @@ struct VerticalTabsSidebar: View { .background(TitlebarDoubleClickMonitorView()) } .overlay(alignment: .topLeading) { - if isMinimalMode { + if isMinimalMode, #unavailable(macOS 26.0) { HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) .padding(.leading, hiddenTitlebarControlsLeadingInset) .padding(.top, 2) @@ -10055,9 +10197,19 @@ struct VerticalTabsSidebar: View { } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() - .background(SidebarBackdrop().ignoresSafeArea()) + .background { + if #available(macOS 26.0, *) { + // On macOS 26, NavigationSplitView provides native glass. + // Don't paint any background over it. + Color.clear.ignoresSafeArea() + } else { + SidebarBackdrop().ignoresSafeArea() + } + } .overlay(alignment: .trailing) { - SidebarTrailingBorder() + if #unavailable(macOS 26.0) { + SidebarTrailingBorder() + } } .background( WindowAccessor { window in @@ -12121,6 +12273,7 @@ private struct SidebarFooterIconButtonStyleBody: View { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.primary.opacity(backgroundOpacity)) ) + .modifier(FooterButtonGlassModifier(isHovered: isHovered)) .onHover { hovering in isHovered = hovering } @@ -12129,6 +12282,24 @@ private struct SidebarFooterIconButtonStyleBody: View { } } +/// On macOS 26+, adds a subtle Liquid Glass effect to sidebar footer buttons on hover. +private struct FooterButtonGlassModifier: ViewModifier { + let isHovered: Bool + + @ViewBuilder + func body(content: Content) -> some View { + #if compiler(>=6.2) + if #available(macOS 26.0, *), isHovered { + content.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 8)) + } else { + content + } + #else + content + #endif + } +} + #if DEBUG private struct SidebarDevFooter: View { @ObservedObject var updateViewModel: UpdateViewModel @@ -12152,39 +12323,6 @@ private struct SidebarDevFooter: View { } #endif -private struct SidebarTopScrim: View { - let height: CGFloat - - var body: some View { - SidebarTopBlurEffect() - .frame(height: height) - .mask( - LinearGradient( - colors: [ - Color.black.opacity(0.95), - Color.black.opacity(0.75), - Color.black.opacity(0.35), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - ) - } -} - -private struct SidebarTopBlurEffect: NSViewRepresentable { - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.blendingMode = .withinWindow - view.material = .underWindowBackground - view.state = .active - view.isEmphasized = false - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} -} private struct SidebarScrollViewResolver: NSViewRepresentable { let onResolve: (NSScrollView?) -> Void @@ -12367,6 +12505,26 @@ enum SidebarTrailingAccessoryWidthPolicy { } } +/// On macOS 26+, applies a native Liquid Glass effect to the active tab background. +/// Applied as a modifier on the background shape so it doesn't add properties to TabItemView +/// and preserves the Equatable optimization. +private struct TabItemGlassModifier: ViewModifier { + let isActive: Bool + + @ViewBuilder + func body(content: Content) -> some View { + #if compiler(>=6.2) + if #available(macOS 26.0, *), isActive { + content.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 6)) + } else { + content + } + #else + content + #endif + } +} + // PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when // the parent rebuilds with unchanged values. Without this, every TabManager // or NotificationStore publish causes ALL tab items to re-evaluate (~18% of @@ -12981,6 +13139,7 @@ private struct TabItemView: View, Equatable { .offset(x: -1) } } + .modifier(TabItemGlassModifier(isActive: isActive)) ) .padding(.horizontal, 6) .background { @@ -15328,6 +15487,53 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable { } } +/// Finds NSSplitView(s) inside NavigationSplitView and hides dividers +/// by walking the view hierarchy and patching divider style/color properties. +@available(macOS 26.0, *) +private struct SplitViewDividerHider: NSViewRepresentable { + func makeNSView(context: Context) -> SplitViewDividerHiderView { + SplitViewDividerHiderView() + } + + func updateNSView(_ nsView: SplitViewDividerHiderView, context: Context) { + nsView.scheduleHide() + } +} + +@available(macOS 26.0, *) +final class SplitViewDividerHiderView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + scheduleHide() + } + + func scheduleHide() { + DispatchQueue.main.async { [weak self] in + self?.hideDividers() + } + } + + private func hideDividers() { + guard let window else { return } + patchSplitViews(in: window.contentView) + } + + private func patchSplitViews(in view: NSView?) { + guard let view else { return } + if let splitView = view as? NSSplitView { + splitView.dividerStyle = .thin + // Clear the divider color via ObjC messaging (private API). + let selector = NSSelectorFromString("setDividerColor:") + if splitView.responds(to: selector) { + splitView.perform(selector, with: NSColor.clear) + } + } + for subview in view.subviews { + patchSplitViews(in: subview) + } + } +} + /// 1px trailing border on the sidebar, derived from the terminal chrome background /// using the same logic as bonsplit's TabBarColors.nsColorSeparator: /// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. @@ -15423,9 +15629,6 @@ private struct SidebarBackdrop: View { ) } - let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) - let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow - let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active let resolvedHex: String = { if colorScheme == .dark, let dark = sidebarTintHexDark { return dark @@ -15435,6 +15638,17 @@ private struct SidebarBackdrop: View { return sidebarTintHex }() let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) + + // On macOS 26+, NavigationSplitView handles the sidebar glass natively. + // Return a clear background so it doesn't fight the system glass. + if #available(macOS 26.0, *) { + return AnyView(Color.clear) + } + + // macOS 13-15: use configurable NSVisualEffectView materials + let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) + let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow + let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active let useLiquidGlass = materialOption?.usesLiquidGlass ?? false let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 517be0a06..3ef9354f8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9115,7 +9115,23 @@ final class GhosttySurfaceScrollView: NSView { let previousSurfaceSize = surfaceView.frame.size _ = setFrameIfNeeded(backgroundView, to: bounds) - _ = setFrameIfNeeded(scrollView, to: bounds) + // On macOS 26, inset the scroll view on the leading and top edges + // to keep terminal text within the rounded corner safe zone. + // Only leading corners are rounded (when sidebar is visible), so + // right/bottom edges use no inset to maximize terminal real estate. + let scrollFrame: CGRect + if #available(macOS 26.0, *) { + let inset: CGFloat = 6 + scrollFrame = CGRect( + x: bounds.origin.x + inset, + y: bounds.origin.y, + width: bounds.width - inset, + height: bounds.height - inset + ) + } else { + scrollFrame = bounds + } + _ = setFrameIfNeeded(scrollView, to: scrollFrame) let targetSize = scrollView.bounds.size #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 2bdc2398f..3b282435d 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1496,6 +1496,23 @@ final class WindowTerminalPortal: NSObject { hostedView.bounds = expectedBounds geometryChanged = true } + // On macOS 26, round the terminal's leading corners when a sidebar + // is visible to its left, matching the NavigationSplitView glass shape. + // Applied inside the CATransaction to prevent animation flicker. + if #available(macOS 26.0, *) { + // Detect sidebar presence via x-offset. The sidebar has a minimum + // width of 120pt, so any x > 20 reliably indicates a sidebar is + // to our left. This AppKit view cannot access SwiftUI SidebarState + // directly; the frame-based heuristic is the simplest reliable path. + let hasSidebarToLeft = targetFrame.origin.x > 20 + let desiredRadius: CGFloat = hasSidebarToLeft ? 16 : 0 + if hostedView.layer?.cornerRadius != desiredRadius { + hostedView.layer?.cornerRadius = desiredRadius + hostedView.layer?.maskedCorners = hasSidebarToLeft + ? [.layerMinXMinYCorner, .layerMinXMaxYCorner] + : [] + } + } CATransaction.commit() if geometryChanged { hostedView.reconcileGeometryNow() diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index bca164006..020af8173 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1022,7 +1022,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont } } -private struct NotificationsPopoverView: View { +struct NotificationsPopoverView: View { @ObservedObject var notificationStore: TerminalNotificationStore @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared let onDismiss: () -> Void @@ -1355,6 +1355,13 @@ final class UpdateTitlebarAccessoryController { guard !attachedWindows.contains(window) else { return } + // On macOS 26, NavigationSplitView's .toolbar provides the controls + // (bell, new tab, etc.), so don't attach the legacy accessory. + if #available(macOS 26.0, *) { + attachedWindows.add(window) + return + } + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == controlsIdentifier }) { let controls = TitlebarControlsAccessoryViewController( notificationStore: TerminalNotificationStore.shared diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 5c9be3014..9a6bb0b5e 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -5,6 +5,9 @@ import SwiftUI @MainActor final class WindowToolbarController: NSObject, NSToolbarDelegate { private let commandItemIdentifier = NSToolbarItem.Identifier("cmux.focusedCommand") + private let sidebarToggleIdentifier = NSToolbarItem.Identifier("cmux.sidebarToggle") + private let notificationsIdentifier = NSToolbarItem.Identifier("cmux.notifications") + private let newTabIdentifier = NSToolbarItem.Identifier("cmux.newTab") private weak var tabManager: TabManager? @@ -123,7 +126,11 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { toolbar.autosavesConfiguration = false toolbar.showsBaselineSeparator = false window.toolbar = toolbar - window.toolbarStyle = .unifiedCompact + if #available(macOS 26.0, *) { + window.toolbarStyle = .unified + } else { + window.toolbarStyle = .unifiedCompact + } window.titleVisibility = .hidden } @@ -154,11 +161,19 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { // MARK: - NSToolbarDelegate func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [commandItemIdentifier, .flexibleSpace] + if #available(macOS 26.0, *) { + return [sidebarToggleIdentifier, notificationsIdentifier, newTabIdentifier, + .flexibleSpace, commandItemIdentifier] + } + return [commandItemIdentifier, .flexibleSpace] } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [commandItemIdentifier, .flexibleSpace] + if #available(macOS 26.0, *) { + return [sidebarToggleIdentifier, notificationsIdentifier, newTabIdentifier, + .flexibleSpace, commandItemIdentifier] + } + return [commandItemIdentifier, .flexibleSpace] } func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { @@ -175,8 +190,57 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { return item } + if #available(macOS 26.0, *) { + if itemIdentifier == sidebarToggleIdentifier { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: String(localized: "toolbar.sidebar.accessibilityDescription", defaultValue: "Toggle Sidebar")) + item.label = String(localized: "toolbar.sidebar.label", defaultValue: "Sidebar") + item.toolTip = String(localized: "toolbar.sidebar.tooltip", defaultValue: "Toggle Sidebar") + item.target = self + item.action = #selector(toggleSidebarAction) + return item + } + + if itemIdentifier == notificationsIdentifier { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.image = NSImage(systemSymbolName: "bell", accessibilityDescription: String(localized: "toolbar.notifications.accessibilityDescription", defaultValue: "Notifications")) + item.label = String(localized: "toolbar.notifications.label", defaultValue: "Notifications") + item.toolTip = String(localized: "toolbar.notifications.tooltip", defaultValue: "Show Notifications") + item.target = self + item.action = #selector(toggleNotificationsAction) + return item + } + + if itemIdentifier == newTabIdentifier { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.image = NSImage(systemSymbolName: "plus", accessibilityDescription: String(localized: "toolbar.newWorkspace.accessibilityDescription", defaultValue: "New Workspace")) + item.label = String(localized: "toolbar.newWorkspace.label", defaultValue: "New Workspace") + item.toolTip = String(localized: "toolbar.newWorkspace.tooltip", defaultValue: "New Workspace") + item.target = self + item.action = #selector(newTabAction) + return item + } + } return nil } + // MARK: - Toolbar Actions (macOS 26+) + + @objc private func toggleSidebarAction() { + _ = AppDelegate.shared?.sidebarState?.toggle() + } + + @objc private func toggleNotificationsAction() { + _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) + } + + @objc private func newTabAction() { + if let appDelegate = AppDelegate.shared { + if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "toolbar.newTab") == nil { + appDelegate.openNewMainWindow(nil) + } + } + } + } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e94abe72c..6035aa65d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6778,7 +6778,18 @@ final class Workspace: Identifiable, ObservableObject { from backgroundColor: NSColor, backgroundOpacity: Double ) -> BonsplitConfiguration.Appearance { - BonsplitConfiguration.Appearance( + let hideTabBar: Bool + if #available(macOS 26.0, *) { + // Set tabBarHeight to 0 on macOS 26. PaneContainerView (bonsplit) + // conditionally shows the tab bar when pane.tabs.count > 1, so + // this effectively hides it only for single-tab panes. + hideTabBar = true + } else { + hideTabBar = false + } + return BonsplitConfiguration.Appearance( + tabBarHeight: hideTabBar ? 0 : 33, + showSplitButtons: !hideTabBar, splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init( diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a2fc95dc3..1978b3686 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -316,40 +316,42 @@ struct cmuxApp: App { defaults.set(targetVersion, forKey: migrationKey) } - var body: some Scene { - WindowGroup { - ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId) - .environmentObject(tabManager) - .environmentObject(notificationStore) - .environmentObject(sidebarState) - .environmentObject(sidebarSelectionState) - .environmentObject(cmuxConfigStore) - .onAppear { + @ViewBuilder + private var mainWindowContent: some View { + ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId) + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(cmuxConfigStore) + .onAppear { #if DEBUG - if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { - UpdateLogStore.shared.append("ui test: cmuxApp onAppear") - } + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { + UpdateLogStore.shared.append("ui test: cmuxApp onAppear") + } #endif - // Start the Unix socket controller for programmatic access - updateSocketController() - appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) - cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager) - cmuxConfigStore.loadAll() - applyAppearance() - if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { - DispatchQueue.main.async { - appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") - } + // Start the Unix socket controller for programmatic access + updateSocketController() + appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) + cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager) + cmuxConfigStore.loadAll() + applyAppearance() + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { + DispatchQueue.main.async { + appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") } } - .onChange(of: appearanceMode) { _ in - applyAppearance() - } - .onChange(of: socketControlMode) { _ in - updateSocketController() - } - } - .windowStyle(.hiddenTitleBar) + } + .onChange(of: appearanceMode) { _ in + applyAppearance() + } + .onChange(of: socketControlMode) { _ in + updateSocketController() + } + } + + var body: some Scene { + WindowGroup { mainWindowContent } .commands { CommandGroup(replacing: .appSettings) { splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) { @@ -3710,6 +3712,13 @@ enum AppIconSettings { } static func applyIcon(_ mode: AppIconMode, environment: Environment = .live()) { + // On macOS 26+, the system handles dark/light/tinted icons natively + // via the asset catalog appearances. Don't override with custom code. + if #available(macOS 26.0, *) { + environment.stopAppearanceObservation() + return + } + switch mode { case .automatic: environment.startAppearanceObservation() diff --git a/vendor/bonsplit b/vendor/bonsplit index b2788b1e7..cfff8a931 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit b2788b1e77d43f0c114dcf189aa59cae8abb47de +Subproject commit cfff8a9318f8131604681e4cb86c11925e01bf1e