826 lines
29 KiB
Swift
826 lines
29 KiB
Swift
import AppKit
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
final class NonDraggableHostingView<Content: View>: NSHostingView<Content> {
|
|
override var mouseDownCanMoveWindow: Bool { false }
|
|
}
|
|
|
|
#if DEBUG
|
|
private struct DevTitlebarAccessoryView: View {
|
|
var body: some View {
|
|
Text("THIS IS A DEV BUILD")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.red)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
|
|
final class DevBuildAccessoryViewController: NSTitlebarAccessoryViewController {
|
|
private let hostingView: NonDraggableHostingView<DevTitlebarAccessoryView>
|
|
private let containerView = NSView()
|
|
private var pendingSizeUpdate = false
|
|
|
|
init() {
|
|
hostingView = NonDraggableHostingView(rootView: DevTitlebarAccessoryView())
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
view = containerView
|
|
containerView.translatesAutoresizingMaskIntoConstraints = true
|
|
hostingView.translatesAutoresizingMaskIntoConstraints = true
|
|
hostingView.autoresizingMask = [.width, .height]
|
|
containerView.addSubview(hostingView)
|
|
|
|
scheduleSizeUpdate()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidAppear() {
|
|
super.viewDidAppear()
|
|
scheduleSizeUpdate()
|
|
}
|
|
|
|
override func viewDidLayout() {
|
|
super.viewDidLayout()
|
|
scheduleSizeUpdate()
|
|
}
|
|
|
|
private func scheduleSizeUpdate() {
|
|
guard !pendingSizeUpdate else { return }
|
|
pendingSizeUpdate = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.pendingSizeUpdate = false
|
|
self?.updateSize()
|
|
}
|
|
}
|
|
|
|
private func updateSize() {
|
|
hostingView.invalidateIntrinsicContentSize()
|
|
hostingView.layoutSubtreeIfNeeded()
|
|
let labelSize = hostingView.fittingSize
|
|
let titlebarHeight = view.window.map { window in
|
|
window.frame.height - window.contentLayoutRect.height
|
|
} ?? labelSize.height
|
|
let containerHeight = max(labelSize.height, titlebarHeight)
|
|
let yOffset = max(0, (containerHeight - labelSize.height) / 2.0)
|
|
preferredContentSize = NSSize(width: labelSize.width, height: containerHeight)
|
|
containerView.frame = NSRect(x: 0, y: 0, width: labelSize.width, height: containerHeight)
|
|
hostingView.frame = NSRect(x: 0, y: yOffset, width: labelSize.width, height: labelSize.height)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private struct TitlebarAccessoryView: View {
|
|
@ObservedObject var model: UpdateViewModel
|
|
|
|
var body: some View {
|
|
UpdatePill(model: model)
|
|
.padding(.trailing, 8)
|
|
}
|
|
}
|
|
|
|
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?
|
|
}
|
|
|
|
private struct NotificationsAnchorView: NSViewRepresentable {
|
|
let onResolve: (NSView) -> Void
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let view = NSView(frame: .zero)
|
|
DispatchQueue.main.async {
|
|
onResolve(view)
|
|
}
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {
|
|
DispatchQueue.main.async {
|
|
onResolve(nsView)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct TitlebarControlButton<Content: View>: View {
|
|
let config: TitlebarControlsStyleConfig
|
|
let action: () -> Void
|
|
@ViewBuilder let content: () -> Content
|
|
@State private var isHovering = false
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
content()
|
|
}
|
|
.buttonStyle(.plain)
|
|
.background(hoverBackground)
|
|
.onHover { isHovering = $0 }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var hoverBackground: some View {
|
|
if config.hoverBackground && isHovering {
|
|
RoundedRectangle(cornerRadius: config.buttonCornerRadius, style: .continuous)
|
|
.fill(Color.primary.opacity(0.08))
|
|
}
|
|
}
|
|
}
|
|
|
|
private 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
|
|
|
|
var body: some View {
|
|
let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic
|
|
let config = style.config
|
|
controlsGroup(config: config)
|
|
.padding(.leading, 4)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func controlsGroup(config: TitlebarControlsStyleConfig) -> some View {
|
|
let content = HStack(spacing: config.spacing) {
|
|
TitlebarControlButton(config: config, action: onToggleSidebar) {
|
|
iconLabel(systemName: "sidebar.left", config: config)
|
|
}
|
|
.accessibilityLabel("Toggle Sidebar")
|
|
.help("Show or hide the sidebar (Cmd+B)")
|
|
|
|
TitlebarControlButton(config: config, action: 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(Color.accentColor)
|
|
)
|
|
.offset(x: config.badgeOffset.width, y: config.badgeOffset.height)
|
|
}
|
|
}
|
|
.frame(width: config.buttonSize, height: config.buttonSize)
|
|
}
|
|
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
|
|
.accessibilityLabel("Notifications")
|
|
.help("Show notifications (Cmd+Shift+I)")
|
|
|
|
TitlebarControlButton(config: config, action: onNewTab) {
|
|
iconLabel(systemName: "plus", config: config)
|
|
}
|
|
.accessibilityLabel("New Tab")
|
|
.help("Open a new tab (Cmd+T or Cmd+N)")
|
|
}
|
|
|
|
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)
|
|
)
|
|
} else {
|
|
paddedContent
|
|
}
|
|
}
|
|
|
|
@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
|
|
}
|
|
}
|
|
}
|
|
|
|
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 let viewModel = TitlebarControlsViewModel()
|
|
private var userDefaultsObserver: NSObjectProtocol?
|
|
|
|
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
|
|
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()
|
|
}
|
|
|
|
scheduleSizeUpdate()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
override func viewDidLayout() {
|
|
super.viewDidLayout()
|
|
scheduleSizeUpdate()
|
|
}
|
|
|
|
private func scheduleSizeUpdate() {
|
|
guard !pendingSizeUpdate else { return }
|
|
pendingSizeUpdate = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.pendingSizeUpdate = false
|
|
self?.updateSize()
|
|
}
|
|
}
|
|
|
|
private func updateSize() {
|
|
hostingView.invalidateIntrinsicContentSize()
|
|
hostingView.layoutSubtreeIfNeeded()
|
|
let contentSize = hostingView.fittingSize
|
|
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)
|
|
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) {
|
|
if notificationsPopover.isShown {
|
|
notificationsPopover.performClose(nil)
|
|
return
|
|
}
|
|
let anchorView = viewModel.notificationsAnchorView ?? hostingView
|
|
notificationsPopover.animates = animated
|
|
notificationsPopover.show(relativeTo: anchorView.bounds, of: anchorView, preferredEdge: .maxY)
|
|
}
|
|
|
|
private func makeNotificationsPopover() -> NSPopover {
|
|
let popover = NSPopover()
|
|
popover.behavior = .semitransient
|
|
popover.animates = true
|
|
popover.delegate = self
|
|
popover.contentViewController = NSHostingController(
|
|
rootView: NotificationsPopoverView(
|
|
notificationStore: notificationStore,
|
|
onDismiss: { [weak popover] in
|
|
popover?.performClose(nil)
|
|
}
|
|
)
|
|
)
|
|
return popover
|
|
}
|
|
}
|
|
|
|
private struct NotificationsPopoverView: View {
|
|
@ObservedObject var notificationStore: TerminalNotificationStore
|
|
let onDismiss: () -> Void
|
|
@FocusState private var focusedNotificationId: UUID?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text("Notifications")
|
|
.font(.headline)
|
|
Spacer()
|
|
if !notificationStore.notifications.isEmpty {
|
|
Button("Clear All") {
|
|
notificationStore.clearAll()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
.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("No notifications yet")
|
|
.font(.headline)
|
|
Text("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) },
|
|
focusedNotificationId: $focusedNotificationId
|
|
)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
.frame(minWidth: 420, idealWidth: 520, maxWidth: 640, minHeight: 320, maxHeight: 480)
|
|
}
|
|
}
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
.onAppear(perform: setInitialFocus)
|
|
.onChange(of: notificationStore.notifications.first?.id) { _ in
|
|
setInitialFocus()
|
|
}
|
|
}
|
|
|
|
private func setInitialFocus() {
|
|
guard let firstId = notificationStore.notifications.first?.id else {
|
|
focusedNotificationId = nil
|
|
return
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
focusedNotificationId = firstId
|
|
}
|
|
}
|
|
|
|
private func tabTitle(for tabId: UUID) -> String? {
|
|
AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == tabId })?.title
|
|
}
|
|
|
|
private func open(_ notification: TerminalNotification) {
|
|
AppDelegate.shared?.tabManager?.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId)
|
|
markReadIfFocused(notification)
|
|
onDismiss()
|
|
}
|
|
|
|
private func markReadIfFocused(_ notification: TerminalNotification) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
guard let tabManager = AppDelegate.shared?.tabManager else { return }
|
|
guard tabManager.selectedTabId == notification.tabId else { return }
|
|
if let surfaceId = notification.surfaceId {
|
|
guard tabManager.focusedSurfaceId(for: notification.tabId) == surfaceId else { return }
|
|
}
|
|
notificationStore.markRead(id: notification.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct NotificationPopoverRow: View {
|
|
let notification: TerminalNotification
|
|
let tabTitle: String?
|
|
let onOpen: () -> Void
|
|
let onClear: () -> Void
|
|
let focusedNotificationId: FocusState<UUID?>.Binding
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Button(action: onOpen) {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Circle()
|
|
.fill(notification.isRead ? Color.clear : Color.accentColor)
|
|
.frame(width: 8, height: 8)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(Color.accentColor.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, style: .time)
|
|
.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)
|
|
.focusable()
|
|
.focused(focusedNotificationId, equals: notification.id)
|
|
.modifier(DefaultActionModifier(isActive: focusedNotificationId.wrappedValue == notification.id))
|
|
|
|
Button(action: onClear) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color(nsColor: .controlBackgroundColor))
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct DefaultActionModifier: ViewModifier {
|
|
let isActive: Bool
|
|
|
|
func body(content: Content) -> some View {
|
|
if isActive {
|
|
content.keyboardShortcut(.defaultAction)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
final class UpdateAccessoryViewController: NSTitlebarAccessoryViewController {
|
|
private let hostingView: NonDraggableHostingView<TitlebarAccessoryView>
|
|
private let containerView = NSView()
|
|
private var stateCancellable: AnyCancellable?
|
|
private var pendingSizeUpdate = false
|
|
|
|
init(model: UpdateViewModel) {
|
|
hostingView = NonDraggableHostingView(rootView: TitlebarAccessoryView(model: model))
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
view = containerView
|
|
containerView.translatesAutoresizingMaskIntoConstraints = true
|
|
hostingView.translatesAutoresizingMaskIntoConstraints = true
|
|
hostingView.autoresizingMask = [.width, .height]
|
|
containerView.addSubview(hostingView)
|
|
|
|
stateCancellable = model.$state
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.scheduleSizeUpdate()
|
|
}
|
|
|
|
scheduleSizeUpdate()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidAppear() {
|
|
super.viewDidAppear()
|
|
scheduleSizeUpdate()
|
|
}
|
|
|
|
override func viewDidLayout() {
|
|
super.viewDidLayout()
|
|
scheduleSizeUpdate()
|
|
}
|
|
|
|
private func scheduleSizeUpdate() {
|
|
guard !pendingSizeUpdate else { return }
|
|
pendingSizeUpdate = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.pendingSizeUpdate = false
|
|
self?.updateSize()
|
|
}
|
|
}
|
|
|
|
private func updateSize() {
|
|
hostingView.invalidateIntrinsicContentSize()
|
|
hostingView.layoutSubtreeIfNeeded()
|
|
let pillSize = hostingView.fittingSize
|
|
let titlebarHeight = view.window.map { window in
|
|
window.frame.height - window.contentLayoutRect.height
|
|
} ?? pillSize.height
|
|
let containerHeight = max(pillSize.height, titlebarHeight)
|
|
let yOffset = max(0, (containerHeight - pillSize.height) / 2.0)
|
|
preferredContentSize = NSSize(width: pillSize.width, height: containerHeight)
|
|
containerView.frame = NSRect(x: 0, y: 0, width: pillSize.width, height: containerHeight)
|
|
hostingView.frame = NSRect(x: 0, y: yOffset, width: pillSize.width, height: pillSize.height)
|
|
}
|
|
}
|
|
|
|
final class UpdateTitlebarAccessoryController {
|
|
private weak var updateViewModel: UpdateViewModel?
|
|
private var didStart = false
|
|
private let attachedWindows = NSHashTable<NSWindow>.weakObjects()
|
|
private var observers: [NSObjectProtocol] = []
|
|
private var stateCancellable: AnyCancellable?
|
|
private var lastIsIdle: Bool?
|
|
private let updateIdentifier = NSUserInterfaceItemIdentifier("cmux.updateAccessory")
|
|
private let controlsIdentifier = NSUserInterfaceItemIdentifier("cmux.titlebarControls")
|
|
#if DEBUG
|
|
private let devIdentifier = NSUserInterfaceItemIdentifier("cmux.devAccessory")
|
|
#endif
|
|
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()
|
|
installStateObserver()
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
|
|
private func attachToExistingWindows() {
|
|
for window in NSApp.windows {
|
|
attachIfNeeded(to: window)
|
|
}
|
|
}
|
|
|
|
private func attachIfNeeded(to window: NSWindow) {
|
|
guard let updateViewModel else { return }
|
|
guard !attachedWindows.contains(window) else { return }
|
|
guard window.styleMask.contains(.titled) else { return }
|
|
guard !isSettingsWindow(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)
|
|
}
|
|
|
|
#if DEBUG
|
|
if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == devIdentifier }) {
|
|
let devAccessory = DevBuildAccessoryViewController()
|
|
devAccessory.layoutAttribute = .right
|
|
devAccessory.view.identifier = devIdentifier
|
|
window.addTitlebarAccessoryViewController(devAccessory)
|
|
}
|
|
#endif
|
|
|
|
if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == updateIdentifier }) {
|
|
let accessory = UpdateAccessoryViewController(model: updateViewModel)
|
|
accessory.layoutAttribute = .right
|
|
accessory.view.identifier = updateIdentifier
|
|
window.addTitlebarAccessoryViewController(accessory)
|
|
}
|
|
|
|
attachedWindows.add(window)
|
|
}
|
|
|
|
private func isSettingsWindow(_ window: NSWindow) -> Bool {
|
|
if window.identifier?.rawValue == "cmux.settings" {
|
|
return true
|
|
}
|
|
return window.title == "Settings"
|
|
}
|
|
|
|
private func installStateObserver() {
|
|
guard let updateViewModel else { return }
|
|
stateCancellable = Publishers.CombineLatest(updateViewModel.$state, updateViewModel.$overrideState)
|
|
.map { state, override in
|
|
override ?? state
|
|
}
|
|
.removeDuplicates()
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] state in
|
|
guard let self else { return }
|
|
let isIdle = state.isIdle
|
|
if let lastIsIdle, lastIsIdle == isIdle {
|
|
return
|
|
}
|
|
self.lastIsIdle = isIdle
|
|
self.refreshAccessories(isIdle: isIdle)
|
|
}
|
|
}
|
|
|
|
private func refreshAccessories(isIdle: Bool) {
|
|
guard let updateViewModel else { return }
|
|
|
|
for window in attachedWindows.allObjects {
|
|
if let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0.view.identifier == updateIdentifier }) {
|
|
window.removeTitlebarAccessoryViewController(at: index)
|
|
}
|
|
|
|
guard !isIdle else { continue }
|
|
|
|
let accessory = UpdateAccessoryViewController(model: updateViewModel)
|
|
accessory.layoutAttribute = .right
|
|
accessory.view.identifier = updateIdentifier
|
|
window.addTitlebarAccessoryViewController(accessory)
|
|
}
|
|
}
|
|
|
|
func toggleNotificationsPopover(animated: Bool = true) {
|
|
for controller in controlsControllers.allObjects {
|
|
controller.toggleNotificationsPopover(animated: animated)
|
|
}
|
|
}
|
|
}
|