Show sidebar/notification/new-tab controls in fullscreen without hovering titlebar (#55)

In fullscreen mode, the NSTitlebarAccessoryViewController buttons are hidden
with the system titlebar. This adds SwiftUI-based fullscreen controls that
appear in the sidebar area (when visible) or inline in the custom titlebar
(when sidebar is hidden), reusing the existing TitlebarControlsView component.

- Track fullscreen state via window notifications and toggle controls visibility
- Hide original titlebar accessory (isHidden + alphaValue=0) in fullscreen
- Route notification popover anchoring through fullscreen controls view model
  so both button clicks and keyboard shortcuts (Cmd+Shift+I) position correctly
- Add debug titlebar spacing slider for fine-tuning leading inset
This commit is contained in:
Lawrence Chen 2026-02-17 20:24:01 -08:00 committed by GitHub
parent 4220c3808f
commit f0e4ccdc1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 117 additions and 14 deletions

View file

@ -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
}

View file

@ -174,6 +174,9 @@ struct ContentView: View {
@State private var selectedTabIds: Set<UUID> = []
@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
}

View file

@ -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<Content: View>: View {
struct TitlebarControlButton<Content: View>: View {
let config: TitlebarControlsStyleConfig
let action: () -> Void
@ViewBuilder let content: () -> Content
@ -221,7 +221,7 @@ private struct TitlebarControlButton<Content: View>: 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 {

View file

@ -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") {