cmux/Sources/Update/UpdateTitlebarAccessory.swift
pstanton237 0fa7160de8
fix: align titlebar icons with traffic-light buttons (#1754)
The titlebar control icons (sidebar, notifications, new tab) were
vertically misaligned — centered within the full titlebar+tab-bar
area instead of the titlebar alone.

Use the close button's superview height as the true titlebar height
so the icons sit at the same vertical center as the traffic lights,
matching the behavior of apps like Slack.
2026-03-20 01:57:35 -07:00

1459 lines
56 KiB
Swift

import AppKit
import Bonsplit
import Combine
import SwiftUI
final class NonDraggableHostingView<Content: View>: NSHostingView<Content> {
override var mouseDownCanMoveWindow: Bool { false }
}
enum TitlebarControlsStyle: Int, CaseIterable, Identifiable {
case classic
case compact
case roomy
case pillGroup
case softButtons
var id: Int { rawValue }
var menuTitle: String {
switch self {
case .classic:
return "Classic"
case .compact:
return "Compact"
case .roomy:
return "Roomy"
case .pillGroup:
return "Pill Group"
case .softButtons:
return "Soft Buttons"
}
}
var config: TitlebarControlsStyleConfig {
switch self {
case .classic:
return TitlebarControlsStyleConfig(
spacing: 10,
iconSize: 15,
buttonSize: 24,
badgeSize: 14,
badgeOffset: CGSize(width: 2, height: -2),
groupBackground: false,
groupPadding: EdgeInsets(),
buttonBackground: false,
buttonCornerRadius: 8,
hoverBackground: false
)
case .compact:
return TitlebarControlsStyleConfig(
spacing: 6,
iconSize: 13,
buttonSize: 20,
badgeSize: 12,
badgeOffset: CGSize(width: 1, height: -1),
groupBackground: false,
groupPadding: EdgeInsets(),
buttonBackground: false,
buttonCornerRadius: 6,
hoverBackground: false
)
case .roomy:
return TitlebarControlsStyleConfig(
spacing: 14,
iconSize: 16,
buttonSize: 28,
badgeSize: 16,
badgeOffset: CGSize(width: 3, height: -3),
groupBackground: false,
groupPadding: EdgeInsets(),
buttonBackground: false,
buttonCornerRadius: 10,
hoverBackground: false
)
case .pillGroup:
return TitlebarControlsStyleConfig(
spacing: 8,
iconSize: 14,
buttonSize: 24,
badgeSize: 14,
badgeOffset: CGSize(width: 2, height: -2),
groupBackground: false,
groupPadding: EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4),
buttonBackground: false,
buttonCornerRadius: 8,
hoverBackground: true
)
case .softButtons:
return TitlebarControlsStyleConfig(
spacing: 8,
iconSize: 15,
buttonSize: 26,
badgeSize: 14,
badgeOffset: CGSize(width: 2, height: -2),
groupBackground: false,
groupPadding: EdgeInsets(),
buttonBackground: true,
buttonCornerRadius: 8,
hoverBackground: false
)
}
}
}
struct TitlebarControlsStyleConfig {
let spacing: CGFloat
let iconSize: CGFloat
let buttonSize: CGFloat
let badgeSize: CGFloat
let badgeOffset: CGSize
let groupBackground: Bool
let groupPadding: EdgeInsets
let buttonBackground: Bool
let buttonCornerRadius: CGFloat
let hoverBackground: Bool
}
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
func makeNSView(context: Context) -> NSView {
let view = AnchorNSView()
view.onLayout = { [weak view] in
guard let view else { return }
onResolve(view)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
final class AnchorNSView: NSView {
var onLayout: (() -> Void)?
override func layout() {
super.layout()
onLayout?()
}
}
struct ShortcutHintLanePlanner {
static func assignLanes(for intervals: [ClosedRange<CGFloat>], minSpacing: CGFloat = 4) -> [Int] {
guard !intervals.isEmpty else { return [] }
var laneMaxX: [CGFloat] = []
var lanes: [Int] = []
lanes.reserveCapacity(intervals.count)
for interval in intervals {
var lane = 0
while lane < laneMaxX.count {
let requiredMinX = laneMaxX[lane] + minSpacing
if interval.lowerBound >= requiredMinX {
break
}
lane += 1
}
if lane == laneMaxX.count {
laneMaxX.append(interval.upperBound)
} else {
laneMaxX[lane] = max(laneMaxX[lane], interval.upperBound)
}
lanes.append(lane)
}
return lanes
}
}
struct ShortcutHintHorizontalPlanner {
static func assignRightEdges(for intervals: [ClosedRange<CGFloat>], minSpacing: CGFloat = 6) -> [CGFloat] {
guard !intervals.isEmpty else { return [] }
var assignedRightEdges = Array(repeating: CGFloat.zero, count: intervals.count)
var nextMaxRight = CGFloat.greatestFiniteMagnitude
for index in stride(from: intervals.count - 1, through: 0, by: -1) {
let interval = intervals[index]
let width = interval.upperBound - interval.lowerBound
let preferredRightEdge = interval.upperBound
let adjustedRightEdge = min(preferredRightEdge, nextMaxRight)
assignedRightEdges[index] = adjustedRightEdge
nextMaxRight = adjustedRightEdge - width - minSpacing
}
return assignedRightEdges
}
}
func titlebarShortcutHintHeight(for config: TitlebarControlsStyleConfig) -> CGFloat {
max(14, config.iconSize + 1)
}
func titlebarShortcutHintVerticalOffset(for config: TitlebarControlsStyleConfig) -> CGFloat {
max(0, floor(config.buttonSize - titlebarShortcutHintHeight(for: config)))
}
struct TitlebarControlButton<Content: View>: View {
let config: TitlebarControlsStyleConfig
let action: () -> Void
@ViewBuilder let content: () -> Content
@State private var isHovering = false
var body: some View {
let baseButton = Button(action: action) {
content()
.frame(width: config.buttonSize, height: config.buttonSize)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(width: config.buttonSize, height: config.buttonSize)
.contentShape(Rectangle())
.background(hoverBackground)
if titlebarControlsShouldTrackButtonHover(config: config) {
baseButton.onHover { isHovering = $0 }
} else {
baseButton
}
}
@ViewBuilder
private var hoverBackground: some View {
if config.hoverBackground && isHovering {
RoundedRectangle(cornerRadius: config.buttonCornerRadius, style: .continuous)
.fill(Color.primary.opacity(0.08))
}
}
}
struct TitlebarControlsView: View {
@ObservedObject var notificationStore: TerminalNotificationStore
@ObservedObject var viewModel: TitlebarControlsViewModel
let onToggleSidebar: () -> Void
let onToggleNotifications: () -> Void
let onNewTab: () -> Void
let visibilityMode: TitlebarControlsVisibilityMode
@AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.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
@State private var isNotificationsPopoverShown = false
@StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor()
private let titlebarHintRightSafetyShift: CGFloat = 10
private let titlebarHintBaseXShift: CGFloat = -10
private enum HintSlot: Int, CaseIterable {
case toggleSidebar
case showNotifications
case newTab
var action: KeyboardShortcutSettings.Action {
switch self {
case .toggleSidebar:
return .toggleSidebar
case .showNotifications:
return .showNotifications
case .newTab:
return .newTab
}
}
}
private struct TitlebarHintLayoutItem: Identifiable {
let action: KeyboardShortcutSettings.Action
let shortcut: StoredShortcut
let width: CGFloat
let leftEdge: CGFloat
var id: String { action.rawValue }
}
private var shouldShowTitlebarShortcutHints: Bool {
alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed
}
private var shouldShowControls: Bool {
if visibilityMode == .alwaysVisible {
return true
}
return isHoveringControls || isNotificationsPopoverShown || 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.)
let _ = shortcutRefreshTick
let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic
let config = style.config
controlsGroup(config: config)
.padding(.leading, 4)
.padding(.trailing, titlebarHintTrailingInset)
.contentShape(Rectangle())
.opacity(shouldShowControls ? 1 : 0)
.allowsHitTesting(shouldShowControls)
.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: KeyboardShortcutSettings.didChangeNotification)) { _ in
shortcutRefreshTick &+= 1
}
.onAppear {
isNotificationsPopoverShown = AppDelegate.shared?.isNotificationsPopoverShown() ?? false
}
.onReceive(NotificationCenter.default.publisher(for: .cmuxNotificationsPopoverVisibilityDidChange)) { notification in
isNotificationsPopoverShown = (notification.userInfo?[NotificationsPopoverVisibilityUserInfoKey.isShown] as? Bool) ?? false
}
.onAppear {
modifierKeyMonitor.start()
}
.onDisappear {
modifierKeyMonitor.stop()
}
}
private var titlebarHintTrailingInset: CGFloat {
// Keep room for blur + shadow so the rightmost hint never clips.
max(0, ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)) + titlebarHintRightSafetyShift + 8
}
private func titlebarHintVerticalBaseOffset(for config: TitlebarControlsStyleConfig) -> CGFloat {
titlebarShortcutHintVerticalOffset(for: config)
}
@ViewBuilder
private func controlsGroup(config: TitlebarControlsStyleConfig) -> some View {
let hintLayoutItems = titlebarHintLayoutItems(config: config)
let content = HStack(spacing: config.spacing) {
TitlebarControlButton(config: config, action: {
#if DEBUG
dlog("titlebar.toggleSidebar")
#endif
onToggleSidebar()
}) {
iconLabel(systemName: "sidebar.left", config: config)
}
.accessibilityIdentifier("titlebarControl.toggleSidebar")
.accessibilityLabel(String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar"))
.safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar")))
TitlebarControlButton(config: config, action: {
#if DEBUG
dlog("titlebar.notifications")
#endif
onToggleNotifications()
}) {
ZStack(alignment: .topTrailing) {
iconLabel(systemName: "bell", config: config)
if notificationStore.unreadCount > 0 {
Text("\(min(notificationStore.unreadCount, 99))")
.font(.system(size: max(8, config.badgeSize - 5), weight: .semibold))
.foregroundColor(.white)
.frame(width: config.badgeSize, height: config.badgeSize)
.background(
Circle().fill(cmuxAccentColor())
)
.offset(x: config.badgeOffset.width, y: config.badgeOffset.height)
}
}
.frame(width: config.buttonSize, height: config.buttonSize)
}
.accessibilityIdentifier("titlebarControl.showNotifications")
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
.accessibilityLabel(String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications"))
.safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications")))
TitlebarControlButton(config: config, action: {
#if DEBUG
dlog("titlebar.newTab")
#endif
onNewTab()
}) {
iconLabel(systemName: "plus", config: config)
}
.accessibilityIdentifier("titlebarControl.newTab")
.accessibilityLabel(String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace"))
.safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace")))
}
let paddedContent = content.padding(config.groupPadding)
if config.groupBackground {
paddedContent
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1)
)
.overlay(alignment: .topLeading) {
titlebarShortcutHintOverlay(items: hintLayoutItems, config: config)
}
} else {
paddedContent
.overlay(alignment: .topLeading) {
titlebarShortcutHintOverlay(items: hintLayoutItems, config: config)
}
}
}
private func titlebarHintLayoutItems(config: TitlebarControlsStyleConfig) -> [TitlebarHintLayoutItem] {
let xOffset = CGFloat(ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))
let intervals = titlebarHintIntervals(config: config, xOffset: xOffset)
guard !intervals.isEmpty else { return [] }
// Keep all titlebar hints on the same Y lane and resolve overlaps by shifting left.
let minimumSpacing: CGFloat = 6
let assignedRightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(
for: intervals.map { $0.interval },
minSpacing: minimumSpacing
)
var items: [TitlebarHintLayoutItem] = []
items.reserveCapacity(intervals.count)
for (index, item) in intervals.enumerated() {
let rightEdge = assignedRightEdges[index]
items.append(
TitlebarHintLayoutItem(
action: item.action,
shortcut: item.shortcut,
width: item.width,
leftEdge: rightEdge - item.width
)
)
}
return items
}
private func titlebarHintIntervals(
config: TitlebarControlsStyleConfig,
xOffset: CGFloat
) -> [(action: KeyboardShortcutSettings.Action, shortcut: StoredShortcut, width: CGFloat, interval: ClosedRange<CGFloat>)] {
guard shouldShowTitlebarShortcutHints else { return [] }
return HintSlot.allCases.compactMap { slot in
let shortcut = KeyboardShortcutSettings.shortcut(for: slot.action)
guard shortcut.command else { return nil }
let width = titlebarHintWidth(for: shortcut, config: config)
let rightEdge = config.groupPadding.leading
+ titlebarButtonRightEdge(for: slot, config: config)
+ xOffset
+ titlebarHintRightSafetyShift
+ titlebarHintBaseXShift
return (slot.action, shortcut, width, (rightEdge - width)...rightEdge)
}
}
private func titlebarHintWidth(for shortcut: StoredShortcut, config: TitlebarControlsStyleConfig) -> CGFloat {
let font = NSFont.systemFont(ofSize: max(8, config.iconSize - 4), weight: .semibold)
let textWidth = (shortcut.displayString as NSString).size(withAttributes: [.font: font]).width
return ceil(textWidth) + 12
}
private func titlebarButtonRightEdge(for slot: HintSlot, config: TitlebarControlsStyleConfig) -> CGFloat {
let index = CGFloat(slot.rawValue)
return (index + 1) * config.buttonSize + index * config.spacing
}
@ViewBuilder
private func titlebarShortcutHintOverlay(
items: [TitlebarHintLayoutItem],
config: TitlebarControlsStyleConfig
) -> some View {
let yOffset = config.groupPadding.top
+ titlebarHintVerticalBaseOffset(for: config)
+ ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset)
ZStack(alignment: .topLeading) {
ForEach(items) { item in
titlebarShortcutHintPill(shortcut: item.shortcut, config: config)
.accessibilityIdentifier("titlebarShortcutHint.\(item.action.rawValue)")
.frame(width: item.width, alignment: .leading)
.offset(x: item.leftEdge, y: yOffset)
}
}
.animation(.easeInOut(duration: 0.14), value: shouldShowTitlebarShortcutHints)
.transition(.opacity)
.allowsHitTesting(false)
}
private func titlebarShortcutHintPill(
shortcut: StoredShortcut,
config: TitlebarControlsStyleConfig
) -> some View {
Text(shortcut.displayString)
.font(.system(size: max(8, config.iconSize - 5), weight: .semibold, design: .rounded))
.monospacedDigit()
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.foregroundColor(.primary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.frame(minHeight: titlebarShortcutHintHeight(for: config))
.background(ShortcutHintPillBackground())
}
@ViewBuilder
private func iconLabel(systemName: String, config: TitlebarControlsStyleConfig) -> some View {
let icon = Image(systemName: systemName)
.font(.system(size: config.iconSize, weight: .semibold))
.frame(width: config.buttonSize, height: config.buttonSize)
if config.buttonBackground {
icon
.background(
RoundedRectangle(cornerRadius: config.buttonCornerRadius)
.fill(Color(nsColor: .controlBackgroundColor).opacity(0.7))
)
} else {
icon
}
}
}
struct HiddenTitlebarSidebarControlsView: View {
@ObservedObject var notificationStore: TerminalNotificationStore
@StateObject private var viewModel = TitlebarControlsViewModel()
private let hostWidth: CGFloat = 124
private let hostHeight: CGFloat = 28
var body: some View {
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() },
visibilityMode: .onHover
)
.frame(width: hostWidth, height: hostHeight, alignment: .leading)
}
}
enum TitlebarControlsVisibilityMode {
case alwaysVisible
case onHover
}
@MainActor
private final class TitlebarShortcutHintModifierMonitor: ObservableObject {
@Published private(set) var isModifierPressed = false
private weak var hostWindow: NSWindow?
private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
private var hostWindowDidResignKeyObserver: NSObjectProtocol?
private var flagsMonitor: Any?
private var keyDownMonitor: Any?
private var appResignObserver: NSObjectProtocol?
private var pendingShowWorkItem: DispatchWorkItem?
func setHostWindow(_ window: NSWindow?) {
guard hostWindow !== window else { return }
removeHostWindowObservers()
hostWindow = window
guard let window else {
cancelPendingHintShow(resetVisible: true)
return
}
hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification,
object: window,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.update(from: NSEvent.modifierFlags, eventWindow: nil)
}
}
hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didResignKeyNotification,
object: window,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.cancelPendingHintShow(resetVisible: true)
}
}
update(from: NSEvent.modifierFlags, eventWindow: nil)
}
func start() {
guard flagsMonitor == nil else {
update(from: NSEvent.modifierFlags, eventWindow: nil)
return
}
flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
self?.update(from: event.modifierFlags, eventWindow: event.window)
return event
}
keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
self?.handleKeyDown(event)
return event
}
appResignObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didResignActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.cancelPendingHintShow(resetVisible: true)
}
}
update(from: NSEvent.modifierFlags, eventWindow: nil)
}
func stop() {
if let flagsMonitor {
NSEvent.removeMonitor(flagsMonitor)
self.flagsMonitor = nil
}
if let keyDownMonitor {
NSEvent.removeMonitor(keyDownMonitor)
self.keyDownMonitor = nil
}
if let appResignObserver {
NotificationCenter.default.removeObserver(appResignObserver)
self.appResignObserver = nil
}
removeHostWindowObservers()
cancelPendingHintShow(resetVisible: true)
}
private func handleKeyDown(_ event: NSEvent) {
guard isCurrentWindow(eventWindow: event.window) else { return }
cancelPendingHintShow(resetVisible: true)
}
private func isCurrentWindow(eventWindow: NSWindow?) -> Bool {
ShortcutHintModifierPolicy.isCurrentWindow(
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
eventWindowNumber: eventWindow?.windowNumber,
keyWindowNumber: NSApp.keyWindow?.windowNumber
)
}
private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) {
guard ShortcutHintModifierPolicy.shouldShowHints(
for: modifierFlags,
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
eventWindowNumber: eventWindow?.windowNumber,
keyWindowNumber: NSApp.keyWindow?.windowNumber
) else {
cancelPendingHintShow(resetVisible: true)
return
}
queueHintShow()
}
private func queueHintShow() {
guard !isModifierPressed else { return }
guard pendingShowWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingShowWorkItem = nil
guard ShortcutHintModifierPolicy.shouldShowHints(
for: NSEvent.modifierFlags,
hostWindowNumber: self.hostWindow?.windowNumber,
hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false,
eventWindowNumber: nil,
keyWindowNumber: NSApp.keyWindow?.windowNumber
) else { return }
self.isModifierPressed = true
}
pendingShowWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem)
}
private func cancelPendingHintShow(resetVisible: Bool) {
pendingShowWorkItem?.cancel()
pendingShowWorkItem = nil
if resetVisible {
isModifierPressed = false
}
}
private func removeHostWindowObservers() {
if let hostWindowDidBecomeKeyObserver {
NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver)
self.hostWindowDidBecomeKeyObserver = nil
}
if let hostWindowDidResignKeyObserver {
NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver)
self.hostWindowDidResignKeyObserver = nil
}
}
}
struct TitlebarControlsLayoutSnapshot: Equatable {
let contentSize: NSSize
let containerHeight: CGFloat
let yOffset: CGFloat
}
func titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyleConfig) -> Bool {
config.hoverBackground
}
func titlebarControlsShouldScheduleForViewSizeChange(
previous: NSSize,
current: NSSize,
tolerance: CGFloat = 0.5
) -> Bool {
guard current.width > 0, current.height > 0 else { return false }
guard previous.width > 0, previous.height > 0 else { return true }
return abs(previous.width - current.width) > tolerance
|| abs(previous.height - current.height) > tolerance
}
func titlebarControlsShouldApplyLayout(
previous: TitlebarControlsLayoutSnapshot?,
next: TitlebarControlsLayoutSnapshot,
tolerance: CGFloat = 0.5
) -> Bool {
guard let previous else { return true }
return abs(previous.contentSize.width - next.contentSize.width) > tolerance
|| abs(previous.contentSize.height - next.contentSize.height) > tolerance
|| abs(previous.containerHeight - next.containerHeight) > tolerance
|| abs(previous.yOffset - next.yOffset) > tolerance
}
final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate {
private let hostingView: NonDraggableHostingView<TitlebarControlsView>
private let containerView = NSView()
private let notificationStore: TerminalNotificationStore
private lazy var notificationsPopover: NSPopover = makeNotificationsPopover()
private var pendingSizeUpdate = false
private var fittingSizeNeedsRefresh = true
private var cachedFittingSize: NSSize?
private var lastObservedViewSize: NSSize = .zero
private var lastAppliedLayoutSnapshot: TitlebarControlsLayoutSnapshot?
private let viewModel = TitlebarControlsViewModel()
private var userDefaultsObserver: NSObjectProtocol?
var popoverIsShownForTesting: Bool { notificationsPopover.isShown }
private var showsWorkspaceTitlebar: Bool { !WorkspacePresentationModeSettings.isMinimal() }
init(notificationStore: TerminalNotificationStore) {
self.notificationStore = notificationStore
let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() }
let toggleNotifications: () -> Void = { _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) }
let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() }
hostingView = NonDraggableHostingView(
rootView: TitlebarControlsView(
notificationStore: notificationStore,
viewModel: viewModel,
onToggleSidebar: toggleSidebar,
onToggleNotifications: toggleNotifications,
onNewTab: newTab,
visibilityMode: .alwaysVisible
)
)
super.init(nibName: nil, bundle: nil)
view = containerView
containerView.translatesAutoresizingMaskIntoConstraints = true
// Prevent the titlebar accessory from clipping button backgrounds
// at the bottom edge (the system constrains accessory height to the
// titlebar, which can be slightly shorter than the button frames).
containerView.wantsLayer = true
containerView.layer?.masksToBounds = false
hostingView.translatesAutoresizingMaskIntoConstraints = true
hostingView.autoresizingMask = [.width, .height]
containerView.addSubview(hostingView)
userDefaultsObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.applyWorkspaceTitlebarVisibility()
self?.scheduleSizeUpdate(invalidateFittingSize: true)
}
applyWorkspaceTitlebarVisibility()
scheduleSizeUpdate(invalidateFittingSize: true)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let userDefaultsObserver {
NotificationCenter.default.removeObserver(userDefaultsObserver)
}
}
override func viewDidAppear() {
super.viewDidAppear()
scheduleSizeUpdate(invalidateFittingSize: true)
}
override func viewDidLayout() {
super.viewDidLayout()
let currentViewSize = view.bounds.size
guard titlebarControlsShouldScheduleForViewSizeChange(
previous: lastObservedViewSize,
current: currentViewSize
) else {
return
}
lastObservedViewSize = currentViewSize
scheduleSizeUpdate(invalidateFittingSize: true)
}
private func scheduleSizeUpdate(invalidateFittingSize: Bool = false) {
if invalidateFittingSize {
fittingSizeNeedsRefresh = true
}
guard !pendingSizeUpdate else { return }
pendingSizeUpdate = true
DispatchQueue.main.async { [weak self] in
self?.pendingSizeUpdate = false
self?.updateSize()
}
}
private func updateSize() {
applyWorkspaceTitlebarVisibility()
guard showsWorkspaceTitlebar else { return }
let contentSize: NSSize
if fittingSizeNeedsRefresh || cachedFittingSize == nil {
hostingView.invalidateIntrinsicContentSize()
hostingView.layoutSubtreeIfNeeded()
cachedFittingSize = hostingView.fittingSize
fittingSizeNeedsRefresh = false
}
contentSize = cachedFittingSize ?? .zero
guard contentSize.width > 0, contentSize.height > 0 else { return }
// Use the traffic-light close button's superview height as the true
// titlebar height. This excludes the tab bar so the icons align with
// the traffic-light buttons (like Slack does) instead of centering in
// the full non-content area which includes the tab strip.
let titlebarHeight: CGFloat = {
if let window = view.window,
let closeButton = window.standardWindowButton(.closeButton),
let titlebarView = closeButton.superview,
titlebarView.frame.height > 0 {
return titlebarView.frame.height
}
// Fallback: derive from the window geometry.
return view.window.map { window in
window.frame.height - window.contentLayoutRect.height
} ?? contentSize.height
}()
let containerHeight = max(contentSize.height, titlebarHeight)
let yOffset = max(0, (containerHeight - contentSize.height) / 2.0)
let nextLayoutSnapshot = TitlebarControlsLayoutSnapshot(
contentSize: contentSize,
containerHeight: containerHeight,
yOffset: yOffset
)
guard titlebarControlsShouldApplyLayout(
previous: lastAppliedLayoutSnapshot,
next: nextLayoutSnapshot
) else {
return
}
lastAppliedLayoutSnapshot = nextLayoutSnapshot
preferredContentSize = NSSize(width: contentSize.width, height: containerHeight)
containerView.frame = NSRect(x: 0, y: 0, width: contentSize.width, height: containerHeight)
hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height)
}
private func applyWorkspaceTitlebarVisibility() {
let shouldShow = showsWorkspaceTitlebar
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)
return
}
// Recreate content view each time to avoid stale observers when popover is hidden
let hostingController = NSHostingController(
rootView: NotificationsPopoverView(
notificationStore: notificationStore,
onDismiss: { [weak notificationsPopover] in
notificationsPopover?.performClose(nil)
}
)
)
hostingController.view.wantsLayer = true
hostingController.view.layer?.backgroundColor = .clear
notificationsPopover.contentViewController = hostingController
guard let window = externalAnchor?.window ?? view.window ?? hostingView.window ?? NSApp.keyWindow,
let contentView = window.contentView else {
return
}
// Force layout to ensure geometry is current.
contentView.layoutSubtreeIfNeeded()
// 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)
postNotificationsPopoverVisibilityDidChange(isShown: true)
return
}
}
if let anchorView = viewModel.notificationsAnchorView, anchorView.window != nil, !isHidden {
anchorView.superview?.layoutSubtreeIfNeeded()
let anchorRect = anchorView.convert(anchorView.bounds, to: contentView)
if !anchorRect.isEmpty {
notificationsPopover.animates = animated
notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY)
postNotificationsPopoverVisibilityDidChange(isShown: true)
return
}
}
// Fallback: position near top-left of the window content.
let bounds = contentView.bounds
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() {
if notificationsPopover.isShown {
notificationsPopover.performClose(nil)
}
}
private func makeNotificationsPopover() -> NSPopover {
let popover = NSPopover()
popover.behavior = .semitransient
popover.animates = true
popover.delegate = self
// Content view controller is set dynamically in toggleNotificationsPopover
return popover
}
// MARK: - NSPopoverDelegate
func popoverDidClose(_ notification: Notification) {
// Clear the content view controller to stop SwiftUI observers when popover is hidden
notificationsPopover.contentViewController = nil
postNotificationsPopoverVisibilityDidChange(isShown: false)
}
}
private struct NotificationsPopoverView: View {
@ObservedObject var notificationStore: TerminalNotificationStore
@AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data()
let onDismiss: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack {
Text(String(localized: "notifications.title", defaultValue: "Notifications"))
.font(.headline)
Spacer()
Button(action: jumpToLatestUnread) {
HStack(spacing: 6) {
Text(String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest"))
Text(jumpToUnreadShortcut.displayString)
}
}
.buttonStyle(.bordered)
.accessibilityIdentifier("notificationsPopover.jumpToLatest")
.accessibilityValue(jumpToUnreadShortcut.displayString)
.safeHelp(
KeyboardShortcutSettings.Action.jumpToUnread.tooltip(
String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest")
)
)
.disabled(!hasUnreadNotifications)
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
notificationStore.clearAll()
}
.buttonStyle(.bordered)
.accessibilityIdentifier("notificationsPopover.clearAll")
.disabled(notificationStore.notifications.isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
Divider()
if notificationStore.notifications.isEmpty {
VStack(spacing: 8) {
Image(systemName: "bell.slash")
.font(.system(size: 28))
.foregroundColor(.secondary)
Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet"))
.font(.headline)
Text(String(localized: "notifications.empty.subtitle", defaultValue: "Desktop notifications will appear here."))
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(minWidth: 420, idealWidth: 520, maxWidth: 640, minHeight: 180)
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(notificationStore.notifications) { notification in
NotificationPopoverRow(
notification: notification,
tabTitle: tabTitle(for: notification.tabId),
onOpen: { open(notification) },
onClear: { notificationStore.remove(id: notification.id) }
)
}
}
.padding(12)
}
.frame(minWidth: 420, idealWidth: 520, maxWidth: 640, minHeight: 320, maxHeight: 480)
}
}
.background(Color(nsColor: .windowBackgroundColor))
}
private func tabTitle(for tabId: UUID) -> String? {
AppDelegate.shared?.tabTitle(for: tabId)
}
private var jumpToUnreadShortcut: StoredShortcut {
decodeShortcut(
from: jumpToUnreadShortcutData,
fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut
)
}
private var hasUnreadNotifications: Bool {
notificationStore.notifications.contains(where: { !$0.isRead })
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
private func jumpToLatestUnread() {
DispatchQueue.main.async {
AppDelegate.shared?.jumpToLatestUnread()
onDismiss()
}
}
private func open(_ notification: TerminalNotification) {
// SwiftUI action closures are not guaranteed to run on the main actor.
// Ensure window focus + tab selection happens on the main thread.
DispatchQueue.main.async {
_ = AppDelegate.shared?.openNotification(
tabId: notification.tabId,
surfaceId: notification.surfaceId,
notificationId: notification.id
)
onDismiss()
}
}
}
private struct NotificationPopoverRow: View {
let notification: TerminalNotification
let tabTitle: String?
let onOpen: () -> Void
let onClear: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 10) {
Button(action: onOpen) {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
.frame(width: 8, height: 8)
.overlay(
Circle()
.stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(notification.title)
.font(.headline)
.foregroundColor(.primary)
Spacer()
Text(notification.createdAt.formatted(date: .omitted, time: .shortened))
.font(.caption)
.foregroundColor(.secondary)
}
if !notification.body.isEmpty {
Text(notification.body)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(3)
}
if let tabTitle {
Text(tabTitle)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer(minLength: 0)
}
.padding(.trailing, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("NotificationPopoverRow.\(notification.id.uuidString)")
// XCUITest's `.click()` is not always reliable for SwiftUI `Button`s hosted in an `NSPopover`.
// Provide an explicit accessibility action so AXPress always routes to `onOpen`.
.accessibilityAction { onOpen() }
Button(action: onClear) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(nsColor: .controlBackgroundColor))
)
}
}
@MainActor
final class UpdateTitlebarAccessoryController {
private weak var updateViewModel: UpdateViewModel?
private var didStart = false
private let attachedWindows = NSHashTable<NSWindow>.weakObjects()
private var observers: [NSObjectProtocol] = []
private var pendingAttachRetries: [ObjectIdentifier: Int] = [:]
private var startupScanWorkItems: [DispatchWorkItem] = []
private let controlsIdentifier = NSUserInterfaceItemIdentifier("cmux.titlebarControls")
private let controlsControllers = NSHashTable<TitlebarControlsAccessoryViewController>.weakObjects()
init(viewModel: UpdateViewModel) {
self.updateViewModel = viewModel
}
deinit {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
}
func start() {
guard !didStart else { return }
didStart = true
attachToExistingWindows()
installObservers()
scheduleStartupWindowScans()
}
func attach(to window: NSWindow) {
attachIfNeeded(to: window)
}
private func installObservers() {
let center = NotificationCenter.default
observers.append(center.addObserver(
forName: NSWindow.didBecomeMainNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let window = notification.object as? NSWindow else { return }
Task { @MainActor [weak self] in
self?.attachIfNeeded(to: window)
}
})
observers.append(center.addObserver(
forName: NSWindow.didBecomeKeyNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let window = notification.object as? NSWindow else { return }
Task { @MainActor [weak self] in
self?.attachIfNeeded(to: window)
}
})
// We intentionally do not rely on "window became visible" notifications here:
// AppKit does not provide a stable cross-SDK API for this. Startup scans handle this case.
}
private func attachToExistingWindows() {
for window in NSApp.windows {
attachIfNeeded(to: window)
}
}
private func scheduleStartupWindowScans() {
// We want to be robust to SwiftUI/AppKit timing and to XCTest automation. Scanning
// NSApp.windows briefly at startup is cheap and ensures accessories are attached even
// if key/main/visible notifications are missed.
let delays: [TimeInterval] = [0.05, 0.15, 0.3, 0.6, 1.0, 2.0, 3.0]
for delay in delays {
let item = DispatchWorkItem { [weak self] in
Task { @MainActor [weak self] in
self?.attachToExistingWindows()
}
#if DEBUG
let env = ProcessInfo.processInfo.environment
if env["CMUX_UI_TEST_MODE"] == "1" {
let ids = NSApp.windows.map { $0.identifier?.rawValue ?? "<nil>" }
let delayText = String(format: "%.2f", delay)
UpdateLogStore.shared.append("startup window scan (delay=\(delayText)) count=\(NSApp.windows.count) ids=\(ids.joined(separator: ","))")
}
#endif
}
startupScanWorkItems.append(item)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
}
}
private func attachIfNeeded(to window: NSWindow) {
guard !isSettingsWindow(window) else { return }
// Window identifiers are assigned by SwiftUI via WindowAccessor, which can run
// after didBecomeKey/didBecomeMain notifications. Retry briefly to avoid missing
// attaching accessories (notably in UI tests).
if !isMainTerminalWindow(window) {
let key = ObjectIdentifier(window)
let attempts = pendingAttachRetries[key, default: 0]
if attempts < 40 {
pendingAttachRetries[key] = attempts + 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak window] in
Task { @MainActor [weak self, weak window] in
guard let self, let window else { return }
self.attachIfNeeded(to: window)
}
}
} else {
pendingAttachRetries.removeValue(forKey: key)
}
return
}
pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window))
guard !WorkspacePresentationModeSettings.isMinimal() else {
removeAccessoryIfPresent(from: window)
return
}
guard !attachedWindows.contains(window) else { return }
if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == controlsIdentifier }) {
let controls = TitlebarControlsAccessoryViewController(
notificationStore: TerminalNotificationStore.shared
)
controls.layoutAttribute = .left
controls.view.identifier = controlsIdentifier
window.addTitlebarAccessoryViewController(controls)
controlsControllers.add(controls)
}
attachedWindows.add(window)
#if DEBUG
let env = ProcessInfo.processInfo.environment
if env["CMUX_UI_TEST_MODE"] == "1" {
let ident = window.identifier?.rawValue ?? "<nil>"
UpdateLogStore.shared.append("attached titlebar accessories to window id=\(ident)")
}
#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))
DispatchQueue.main.async { [weak window] in
guard let window else { return }
window.contentView?.needsLayout = true
window.contentView?.superview?.needsLayout = true
window.contentView?.layoutSubtreeIfNeeded()
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 ?? "<nil>"
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
}
return window.title == "Settings"
}
private func isMainTerminalWindow(_ window: NSWindow) -> Bool {
guard let raw = window.identifier?.rawValue else { return false }
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
}
private func preferredNotificationsController(
from controllers: [TitlebarControlsAccessoryViewController],
preferShownPopover: Bool
) -> TitlebarControlsAccessoryViewController? {
if let keyWindow = NSApp.keyWindow,
let match = controllers.first(where: { $0.view.window === keyWindow }) {
return match
}
if let keyMain = NSApp.windows.first(where: { $0.isKeyWindow && isMainTerminalWindow($0) }),
let match = controllers.first(where: { $0.view.window === keyMain }) {
return match
}
if preferShownPopover,
let shown = controllers.first(where: { $0.popoverIsShownForTesting }) {
return shown
}
return controllers.first
}
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 {
controller.dismissNotificationsPopover()
}
}
target?.toggleNotificationsPopover(animated: animated)
}
func isNotificationsPopoverShown() -> Bool {
controlsControllers.allObjects.contains(where: { $0.popoverIsShownForTesting })
}
@discardableResult
func dismissNotificationsPopoverIfShown() -> Bool {
let controllers = controlsControllers.allObjects
var dismissed = false
for controller in controllers where controller.popoverIsShownForTesting {
controller.dismissNotificationsPopover()
dismissed = true
}
return dismissed
}
func showNotificationsPopover(animated: Bool = true) {
let controllers = controlsControllers.allObjects
guard !controllers.isEmpty else { return }
let target = preferredNotificationsController(from: controllers, preferShownPopover: false)
for controller in controllers {
if controller !== target {
controller.dismissNotificationsPopover()
}
}
guard let target else { return }
if target.popoverIsShownForTesting {
return
}
target.toggleNotificationsPopover(animated: animated)
}
}