cmux/Sources/Update/UpdateTitlebarAccessory.swift
Lawrence Chen 459a0289c1
Fix titlebar shortcut hint clipping (#1259)
* Add regression test for titlebar shortcut hint clipping

* Fix titlebar shortcut hint clipping
2026-03-12 03:27:10 -07:00

1308 lines
50 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?
}
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
@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
@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
}
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)
.background(
WindowAccessor { window in
modifierKeyMonitor.setHostWindow(window)
}
.frame(width: 0, height: 0)
)
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
shortcutRefreshTick &+= 1
}
.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
}
}
}
@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 }
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
)
)
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?.scheduleSizeUpdate(invalidateFittingSize: true)
}
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() {
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 }
let titlebarHeight = 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)
}
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)
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)
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)
}
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
}
}
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))
)
}
}
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 }
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 }
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
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 !attachedWindows.contains(window) else { return }
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
guard let self, let window else { return }
self.attachIfNeeded(to: window)
}
} else {
pendingAttachRetries.removeValue(forKey: key)
}
return
}
pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window))
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 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)
}
}