#if os(macOS) import AppKit import SwiftUI /// The controls in the call bar, for keyboard focus: while one has it, /// the bar stays up. enum CallControlID: Hashable { case mute, camera, share, devices, layout, link, chat, leave } /// The full-window call: the picture edge to edge, to the very top of /// the window, with the traffic lights, the controls and the panels over /// it, and — for a meeting someone named — its title, centred where the /// window's title would be (CallTitle). There is no title bar: a strip across the top drags /// the window instead. The controls and the traffic lights come with the /// pointer and go 3 s after it stops, or 1.5 s after it leaves the /// window (see ControlsVisibility for what keeps them up). Notices that /// matter stay whatever the controls do. extension MacCallView { /// Room kept clear at the top for the title bar's traffic lights. static let titleBarHeight: CGFloat = 32 /// Height of the floating bar with its margin below: what the self /// view and a tile's name move up by while the bar shows. static let barReserve: CGFloat = 44 + 20 + 16 static let panelWidth: CGFloat = 320 static let tileBackdrop = Color(white: 0.13) /// Where the traffic lights sit, from the window's top-left corner: /// the pointer over them holds the controls (and them) up, and the /// title keeps to the right of it. static let windowButtonsArea = CGRect(x: 0, y: 0, width: 80, height: titleBarHeight) var immersive: some View { GeometryReader { geometry in let size = geometry.size ZStack { Color.black if let fixtureBackdrop { fixtureBackdrop.view.accessibilityHidden(true).movesWindow() } else { fullWindowStage(in: size).movesWindow() } if !grid, selfFloats { selfView(in: size) } topShade // Above the picture, below the title and everything else: what // is left of the strip once they have their clicks drags. WindowDragStrip() .frame(height: Self.titleBarHeight) .frame(maxHeight: .infinity, alignment: .top) topOverlay panels(in: size) bar(in: size) } .coordinateSpace(name: "call") .frame(width: size.width, height: size.height) } .ignoresSafeArea() .background( CallWindowChrome( title: CallTitle.window(title: call.title), buttonsVisible: chrome.visible, fade: ControlsVisibility.fadeDuration(showing: chrome.visible, reduceMotion: reduceMotion))) .background( CallWindowActivity( moved: { point in chrome.pointerMoved() chrome.set( .pointerOverControls, chrome.visible && (barFrame.contains(point) || Self.windowButtonsArea.contains(point))) }, exited: { chrome.pointerExited() }, activity: { chrome.activity() }, menuTracking: { menuTracking = $0 }, sheet: { sheetShowing = $0 }) ) .animation(fade, value: chrome.visible) .onAppear { updateHolds() } .onChange(of: showingDevices) { updateHolds() } .onChange(of: showingChat) { updateHolds() } .onChange(of: showingLayout) { updateHolds() } .onChange(of: showingMore) { updateHolds() } .onChange(of: menuTracking) { updateHolds() } .onChange(of: sheetShowing) { updateHolds() } .onChange(of: voiceOver) { updateHolds() } .onChange(of: focusedControl) { _, now in chrome.set(.keyboardFocus, now != nil) } .onChange(of: chrome.state.hidesPointer) { _, hide in // Hidden until it moves, which is also what brings the controls // back: AppKit shows it again on the first movement by itself. if hide, chrome.pinned == nil { NSCursor.setHiddenUntilMouseMoves(true) } } } private var fade: Animation { ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion) } /// A shade down from the top edge while the controls show, so the /// traffic lights and the title read over a bright picture. private var topShade: some View { // Dark enough through the title bar's strip for a named meeting's // title, white, over a white picture (4.5:1), then fading. LinearGradient( stops: [ .init(color: .black.opacity(0.72), location: 0), .init(color: .black.opacity(0.62), location: Self.titleBarHeight / 112), .init(color: .black.opacity(0), location: 1), ], startPoint: .top, endPoint: .bottom ) .frame(height: 112) .frame(maxHeight: .infinity, alignment: .top) .opacity(chrome.visible ? 1 : 0) .allowsHitTesting(false) .accessibilityHidden(true) } private func updateHolds() { chrome.set( .menuOpen, showingDevices || showingChat || showingLayout || showingMore || menuTracking) chrome.set(.alert, sheetShowing) chrome.set(.voiceOver, voiceOver) } /// How far up a name at the bottom of the stage sits, clear of the bar. private var lift: CGFloat { chrome.visible ? Self.barReserve - 8 : 0 } // MARK: Stage @ViewBuilder private func fullWindowStage(in size: CGSize) -> some View { Group { if grid { fullWindowGrid(in: size) } else { speakerStage(in: size) } } .coordinateSpace(name: arrangeSpace) .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 } } /// Speaker view: the stage fills the whole window, edge to edge, as /// FaceTime's does, and everyone else is a filmstrip floating over its /// bottom, in this viewer's order (2026-10-05: until then the stage /// stopped above the filmstrip, leaving a black band across the /// window behind it and the bar). The filmstrip rises clear of the bar /// while the controls show, as the self view does, and settles to the /// window's edge when they go. The stage's name sits at the window's /// bottom left, just above the filmstrip (`StageLabel`). In a call of /// two there is no filmstrip: the other person fills the window and /// you float in a corner, as since 0.3.4. private func speakerStage(in size: CGSize) -> some View { let strip = selfFloats ? [] : filmstrip let thumb = Self.filmstripTile(in: size) let band = strip.isEmpty ? 0 : thumb.height + 2 * Self.filmstripPadding let stageLift = StageLabel.lift(bar: lift, band: band) return ZStack(alignment: .bottom) { Group { if let focused { macTile(focused, cornerRadius: 0, compact: true, labelLift: stageLift, onStage: true) .overlay(alignment: .bottomTrailing) { if arrangement.pinned == focused { PinBadge(title: itemTitle(focused)) { withAnimation(panelAnimation) { arrangement.pin(nil) } } .padding(8).padding(.bottom, stageLift) } } } else { ContentUnavailableView( "Waiting for others", systemImage: "person.2", description: Text("Your meeting is ready. Others will appear here when they join.") ) .foregroundStyle(.white.opacity(0.85)) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .animation(fade, value: chrome.visible) if !strip.isEmpty { filmstripView(strip, tile: thumb, width: size.width) .padding(.bottom, chrome.visible ? Self.barReserve - 8 : 0) .animation(fade, value: chrome.visible) } } } /// Room above and below the filmstrip's tiles. static let filmstripPadding: CGFloat = 8 /// A filmstrip tile's size: a sixth of the window's height, between /// 72 and 120 pt tall, at 16:9. At the smallest window (760 × 540) /// that is 86 pt, and five of them fit across without scrolling. static func filmstripTile(in size: CGSize) -> CGSize { let height = min(120, max(72, (size.height / 6).rounded())) return CGSize(width: (height * 16 / 9).rounded(), height: height) } /// The filmstrip: centred while it fits, scrolling sideways once it /// does not. private func filmstripView(_ strip: [StageItem], tile: CGSize, width: CGFloat) -> some View { ScrollView(.horizontal) { HStack(spacing: 8) { ForEach(strip, id: \.self) { item in macTile(item, cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop, iconOnly: true) .frame(width: tile.width, height: tile.height) .modifier(arrangeable(item, tapToPin: true)) } } .padding(.horizontal, 12).padding(.vertical, Self.filmstripPadding) .frame(minWidth: width) // The click that brings the window forward pins too, as it does // on the picture; it does not reach through a scroll view unless // the content asks again. .takesActivatingClicks() } .scrollIndicators(.automatic) .accessibilityElement(children: .contain) .accessibilityLabel("Filmstrip") .accessibilityIdentifier("filmstrip") } /// Every tile as large as it can be without scrolling, until there are /// so many that they would be too small to see, when it scrolls. @ViewBuilder private func fullWindowGrid(in size: CGSize) -> some View { let items = gridItems let count = items.count let area = CGSize(width: size.width - 8, height: size.height - 8) let columns = GridFit.columns(count: count, in: area, aspect: 16 / 9) let fitted = GridFit.tile(count: count, columns: columns, in: area, aspect: 16 / 9) let tile = fitted.width >= 200 ? fitted : CGSize(width: 200, height: 200 * 9 / 16) let across = fitted.width >= 200 ? columns : max(1, Int((area.width + 4) / (tile.width + 4))) let rows = (count + across - 1) / across let gridHeight = CGFloat(rows) * tile.height + CGFloat(max(rows - 1, 0)) * 4 // The last row's names clear the bar while it shows: lifted by as // much of the bar as reaches over them (the grid is centred). let below = max(0, (size.height - 8 - gridHeight) / 2) + 4 let lastLift = max(0, lift - below) let firstOfLastRow = (rows - 1) * across ScrollView { LazyVGrid( columns: Array(repeating: GridItem(.fixed(tile.width), spacing: 4), count: across), spacing: 4 ) { ForEach(Array(items.enumerated()), id: \.element) { index, item in macTile( item, cornerRadius: 6, compact: true, labelLift: index >= firstOfLastRow ? lastLift : 0, backdrop: Self.tileBackdrop ) .frame(width: tile.width, height: tile.height) .modifier(arrangeable(item, tapToPin: false)) } } .padding(4) .frame(minHeight: size.height) } .scrollIndicators(.never) } // MARK: Self view /// You, small and floating: dragged anywhere, settling in the nearest /// corner, kept clear of the bar while it shows and of an open chat. /// Muted shows on it whether or not the controls do. private func selfView(in size: CGSize) -> some View { let corner = PiPCorner(rawValue: pipCorner) ?? .bottomTrailing let pip = PiPLayout.size(in: size, touch: false) let frame = PiPLayout.frame(corner: corner, size: pip, in: size, insets: selfInsets(in: size)) return MacVideoTile( track: call.localTrack, label: "You", muted: call.muted, mirrored: true, cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 10, compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio ) .frame(width: pip.width, height: pip.height) .shadow(color: .black.opacity(0.35), radius: 8, y: 3) .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height) .gesture( DragGesture() .onChanged { pipDrag = $0.translation } .onEnded { drag in let dropped = CGPoint( x: frame.midX + drag.translation.width, y: frame.midY + drag.translation.height) let headed = CGPoint( x: frame.midX + drag.predictedEndTranslation.width, y: frame.midY + drag.predictedEndTranslation.height) withAnimation(reduceMotion ? nil : .spring(duration: 0.3)) { pipCorner = PiPLayout.corner(droppedAt: dropped, predicted: headed, in: size).rawValue pipDrag = .zero } } ) .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: frame) .accessibilityActions { ForEach(PiPCorner.allCases, id: \.self) { place in Button(place.moveTitle) { pipCorner = place.rawValue } } } } private func selfInsets(in size: CGSize) -> EdgeInsets { var trailing: CGFloat = 0 if showingChat { trailing += Self.panelWidth + 12 } // The title bar's strip, with the traffic lights and any title in // it, is the only thing at the top: a self view goes below it. return EdgeInsets( top: Self.titleBarHeight, leading: 0, bottom: chrome.visible ? Self.barReserve - 12 : 0, trailing: trailing) } // MARK: Title and notices private var notices: [CallNotice] { CallNotices.notices( phase: call.noticePhase, status: call.status, sharingScreen: call.sharingScreen, screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery) } private var topOverlay: some View { // No pill (2026-10-02, Paul approved): how the call is going is the // notices' to say, and the title — only one someone chose — is // plain text centred in the title bar's strip, level with the // traffic lights, as a window's title is. The strip's dark shade // (topShade) keeps it legible over a bright picture, and it fades // with the controls and the traffic lights. ZStack(alignment: .top) { if let shown = CallTitle.shown(title: call.title, named: call.named) { Text(shown) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white) .shadow(color: .black.opacity(0.45), radius: 2, y: 1) .lineLimit(1).truncationMode(.tail) // Clear of the traffic lights on the left and the same on the // right, so it stays centred on the window. .padding(.horizontal, Self.windowButtonsArea.maxX + 8) .frame(maxWidth: .infinity) .frame(height: Self.titleBarHeight) .opacity(chrome.visible ? 1 : 0) .allowsHitTesting(false) .accessibilityHidden(!chrome.visible) .accessibilityAddTraits(.isHeader) .accessibilityIdentifier("callTitle") } VStack(spacing: 8) { ForEach(notices.filter { $0.important && !dismissedNotices.contains($0.id) }) { notice in noticeView(notice) } ForEach(call.state?.waiting ?? []) { peer in HStack(spacing: 12) { Label("\(peer.name) is waiting", systemImage: "person.badge.clock") Button("Let in") { call.admit(peer.peer) }.buttonStyle(.borderedProminent) } .modifier(NoticeSurface()) } // A notice that is not urgent — the relay warning — goes with // the controls; it lived in the pill until the pill went. ForEach(notices.filter { !$0.important }) { notice in noticeView(notice) .opacity(chrome.visible ? 1 : 0) .allowsHitTesting(chrome.visible) .accessibilityHidden(!chrome.visible) } } .padding(.horizontal, 90) .padding(.top, Self.titleBarHeight + 6) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } @ViewBuilder private func noticeView(_ notice: CallNotice) -> some View { HStack(spacing: 12) { switch notice.kind { case .sharing: Label(notice.text, systemImage: "rectangle.on.rectangle") .foregroundStyle(CallOverlay.alertText).bold() case .screenRecovering: Label(notice.text, systemImage: "arrow.clockwise") case .connecting, .reconnecting, .waitingToJoin: // Drawn rather than AppKit's spinner, which stays dark-on-dark // on this surface in a light-appearance app. Image(systemName: "arrow.triangle.2.circlepath") .symbolEffect(.pulse, isActive: !reduceMotion) .accessibilityHidden(true) Text(notice.text) case .full: Label(notice.text, systemImage: "person.3.fill") Button("Try joining again") { call.retryJoin() } case .ended: Label(notice.text, systemImage: "phone.down") if let rejoin { Button("Join again", action: rejoin).buttonStyle(.borderedProminent) .accessibilityIdentifier("joinAgain") } case .problem, .floor: Label(notice.text, systemImage: "exclamationmark.triangle.fill") } if let action = notice.action { NoticeActionButton(action) { call.perform(action) } } if notice.dismissible { Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) } .labelStyle(.iconOnly).buttonStyle(.borderless) .help("Dismiss") } } .modifier(NoticeSurface()) .accessibilityElement(children: .contain) } // MARK: Panels @ViewBuilder private func panels(in size: CGSize) -> some View { let top = Self.titleBarHeight + 8 // Below the title, which shows while a panel is open. let devicesTop = Self.titleBarHeight + 40 let bottom = Self.barReserve + 8 if showingDevices { VStack(spacing: 0) { HStack { Text("Devices").font(.headline) Spacer() Button("Hide devices", systemImage: "xmark") { withAnimation(panelAnimation) { showingDevices = false } } .labelStyle(.iconOnly).buttonStyle(.borderless).help("Hide devices") }.padding(.horizontal, 16).padding(.top, 14) // Its own height when that fits, scrolling when it does not. ScrollView { DevicePanel(call: call) .background( GeometryReader { panel in Color.clear .onAppear { devicesHeight = panel.size.height } .onChange(of: panel.size.height) { _, now in devicesHeight = now } }) } .frame(height: max(80, min(devicesHeight, size.height - devicesTop - bottom - 44))) } .frame(width: Self.panelWidth) // Read, not glanced at, and over video: opaque and dark, with the // dark appearance's full-contrast labels, whatever the system's // appearance (a light material over a picture was grey on grey). // It is not part of the controls' fade: it holds them up while // open (`.menuOpen`) and carries no opacity of its own. .foregroundStyle(.white) .background(CallOverlay.panelFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .environment(\.colorScheme, .dark) .shadow(color: .black.opacity(0.35), radius: 14, y: 4) .accessibilityIdentifier("devicesPanel") .padding(.leading, 16).padding(.bottom, bottom) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) .transition(panelTransition(edge: .bottom)) } if showingChat { VStack(spacing: 14) { HStack { Text("Chat").font(.headline) Spacer() Button("Open chat in a window", systemImage: "arrow.up.forward.app") { showingChat = false chatWindow.open(call: call) }.help("Open chat in a separate window") Button("Hide chat", systemImage: "xmark") { withAnimation(panelAnimation) { showingChat = false } } .help("Hide chat") }.labelStyle(.iconOnly).buttonStyle(.borderless) ChatView(call: call, draft: chatWindow.draft) } .padding(16).frame(width: Self.panelWidth) .frame(maxHeight: .infinity) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.25), radius: 12, y: 4) .padding(.top, top).padding(.trailing, 12).padding(.bottom, bottom) .frame(maxWidth: .infinity, alignment: .trailing) .transition(panelTransition(edge: .trailing)) } } /// Panels slide in from their edge; with Reduce Motion they only fade. private func panelTransition(edge: Edge) -> AnyTransition { reduceMotion ? .opacity : .move(edge: edge).combined(with: .opacity) } // MARK: Bar /// The bar: centred on the window and whole at every width /// (CallBarFit), its width set from the window's rather than left to /// whatever its row asks for — tightening, then moving the least used /// controls into ⋯, before anything could reach past an edge. private func bar(in size: CGSize) -> some View { let fit = CallBarFit.fit(windowWidth: size.width, controls: barControls) return controls(fullWindow: true, fit: fit) .padding(fit.spacing.padding) .frame(width: fit.width) // One glass pane, no edge, the buttons sitting on it. .callOverlaySurface(Capsule()) .shadow(color: .black.opacity(0.25), radius: 12, y: 4) .background( GeometryReader { bar in Color.clear .onAppear { barFrame = bar.frame(in: .named("call")) CallBarProbe.pane = bar.frame(in: .global) } .onChange(of: bar.frame(in: .named("call"))) { _, now in barFrame = now } .onChange(of: bar.frame(in: .global)) { _, now in CallBarProbe.pane = now } } ) .padding(.bottom, CallBarFit.margin) .frame(width: size.width, height: size.height, alignment: .bottom) .opacity(chrome.visible ? 1 : 0) .allowsHitTesting(chrome.visible) // Hidden from sight but never from VoiceOver, which holds the bar // up anyway; hidden from the keyboard's Tab, which brings it back. .accessibilityHidden(!chrome.visible) } } extension PiPCorner { var moveTitle: String { switch self { case .topLeading: "Move self view to top left" case .topTrailing: "Move self view to top right" case .bottomLeading: "Move self view to bottom left" case .bottomTrailing: "Move self view to bottom right" } } } /// A notice over the picture: a capsule of the overlay's borderless /// glass, with white text and dark-appearance controls, whatever the /// system's. struct NoticeSurface: ViewModifier { func body(content: Content) -> some View { content.font(.callout).foregroundStyle(.white) .environment(\.colorScheme, .dark) .padding(.horizontal, 14).padding(.vertical, 8) // Without Liquid Glass: its lit rim is the faint light edge Paul // saw round the sharing notice (2026-09-28), and no overlay has // an edge since 0.3.7. .callOverlaySurface(Capsule(), rim: false) .shadow(color: .black.opacity(0.25), radius: 8, y: 2) } } /// The call window's own chrome while the full-window call shows: no /// title bar — transparent, no title, the content under it to the very /// top — and the traffic lights over the picture, fading with the /// controls. Everything is put back when the call leaves the window or /// changes to Classic, and none of it touches full screen or a panel. /// /// SwiftUI keeps its own idea of the window's title bar and can put it /// back while the call is up (0.3.4 showed an opaque bar over the call); /// so while this view is in the window it watches those properties and /// sets them again whenever something else changes them. /// /// The window's title is the meeting's while the call is in it, named /// or not (CallTitle.window): hidden on screen with the rest of the /// title bar, it is still what the Window menu, Mission Control and /// VoiceOver read. It is put back with everything else. struct CallWindowChrome: NSViewRepresentable { var title: String var buttonsVisible: Bool var fade: TimeInterval func makeNSView(context: Context) -> ChromeView { ChromeView() } func updateNSView(_ view: ChromeView, context: Context) { view.setTitle(title) view.setButtons(visible: buttonsVisible, fade: fade) } static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.release() } final class ChromeView: NSView { private weak var styled: NSWindow? private var saved: (transparent: Bool, title: NSWindow.TitleVisibility, full: Bool, separator: NSTitlebarSeparatorStyle, text: String)? private var title = "" private var watches: [NSKeyValueObservation] = [] private var observers: [NSObjectProtocol] = [] private var buttonsVisible = true override func viewDidMoveToWindow() { super.viewDidMoveToWindow() guard let window, !(window is NSPanel) else { release() return } guard styled !== window else { return } release() styled = window saved = ( window.titlebarAppearsTransparent, window.titleVisibility, window.styleMask.contains(.fullSizeContentView), window.titlebarSeparatorStyle, window.title ) apply(to: window) if !title.isEmpty { window.title = title } showButtons(animated: false) watches = [ window.observe(\.titlebarAppearsTransparent) { [weak self] window, _ in MainActor.assumeIsolated { self?.apply(to: window) } }, window.observe(\.styleMask) { [weak self] window, _ in MainActor.assumeIsolated { self?.apply(to: window) } }, // SwiftUI writes the scene's own title ("Calls") back now and // then; the meeting's goes back over it. window.observe(\.title) { [weak self] window, _ in MainActor.assumeIsolated { guard let self, window === self.styled, !self.title.isEmpty, window.title != self.title else { return } window.title = self.title } }, ] let center = NotificationCenter.default observers = [ NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification, NSWindow.didBecomeKeyNotification, NSWindow.didResizeNotification, ].map { name in center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.showButtons(animated: false) } } } } /// Hidden title bar, content to the top. Only what differs is set, /// so the watches that call this do not call it again. private func apply(to window: NSWindow) { guard window === styled else { return } if !window.titlebarAppearsTransparent { window.titlebarAppearsTransparent = true } if window.titleVisibility != .hidden { window.titleVisibility = .hidden } if !window.styleMask.contains(.fullSizeContentView) { window.styleMask.insert(.fullSizeContentView) } if window.titlebarSeparatorStyle != .none { window.titlebarSeparatorStyle = .none } } func setTitle(_ text: String) { title = text if let window = styled, window.title != text { window.title = text } } func setButtons(visible: Bool, fade: TimeInterval) { guard visible != buttonsVisible else { return } buttonsVisible = visible NSAnimationContext.runAnimationGroup { context in context.duration = fade context.timingFunction = CAMediaTimingFunction(name: .easeOut) showButtons(animated: true) } } /// The traffic lights, shown or hidden with the controls; always /// shown in full screen, where the system reveals them with the menu /// bar, and when the call lets the window go. private func showButtons(animated: Bool) { guard let window = styled else { return } let fullScreen = window.styleMask.contains(.fullScreen) let alpha: CGFloat = buttonsVisible || fullScreen ? 1 : 0 for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { guard let button = window.standardWindowButton(kind) else { continue } if animated { button.animator().alphaValue = alpha } else { button.alphaValue = alpha } } } /// Puts the window back as it was found. func release() { watches.forEach { $0.invalidate() } watches = [] observers.forEach(NotificationCenter.default.removeObserver) observers = [] guard let window = styled, let saved else { return } styled = nil self.saved = nil for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { window.standardWindowButton(kind)?.alphaValue = 1 } window.titlebarAppearsTransparent = saved.transparent window.titleVisibility = saved.title window.titlebarSeparatorStyle = saved.separator window.title = saved.text if !saved.full { window.styleMask.remove(.fullSizeContentView) } } override func viewWillMove(toWindow next: NSWindow?) { super.viewWillMove(toWindow: next) if next !== styled { release() } } override func hitTest(_ point: NSPoint) -> NSView? { nil } } } /// The strip across the top of the full-window call that stands in for /// the title bar it replaces: dragging it moves the window, and a double /// click does what the person's "double-click a window's title bar" /// setting says (zoom, minimise, or nothing). struct WindowDragStrip: NSViewRepresentable { func makeNSView(context: Context) -> StripView { StripView() } func updateNSView(_ view: StripView, context: Context) {} final class StripView: NSView { override var mouseDownCanMoveWindow: Bool { true } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func mouseDown(with event: NSEvent) { guard let window else { return } if event.clickCount == 2 { TitleBarDoubleClick.current().perform(on: window) } else { window.performDrag(with: event) } } } } /// What a double click on a title bar does, from System Settings › /// Desktop & Dock: "Double-click a window's title bar to…". enum TitleBarDoubleClick: Equatable { case zoom, minimize, none static func current(_ defaults: UserDefaults = .standard) -> TitleBarDoubleClick { from(action: defaults.string(forKey: "AppleActionOnDoubleClick"), miniaturize: defaults.object(forKey: "AppleMiniaturizeOnDoubleClick") as? Bool) } /// "Maximize" (zoom) is the default, and "Fill" zooms too; the older /// boolean says minimise when the newer key is absent. static func from(action: String?, miniaturize: Bool?) -> TitleBarDoubleClick { switch action { case "Minimize": return .minimize case "None": return .none case nil: return miniaturize == true ? .minimize : .zoom default: return .zoom } } } /// What the call window's pointer and keyboard are doing, for the /// controls' visibility: the pointer moving (with where, in the view's /// own top-left coordinates) and leaving, Tab pressed, a menu tracking /// anywhere in the app, a sheet on the window. Tracks the pointer even /// while the app is not active, as a video player does. struct CallWindowActivity: NSViewRepresentable { var moved: (CGPoint) -> Void var exited: () -> Void /// Tab pressed, or a click: the person is here. var activity: () -> Void var menuTracking: (Bool) -> Void var sheet: (Bool) -> Void func makeNSView(context: Context) -> ActivityView { ActivityView() } func updateNSView(_ view: ActivityView, context: Context) { view.handlers = self } static func dismantleNSView(_ view: ActivityView, coordinator: ()) { view.stop() } final class ActivityView: NSView { var handlers: CallWindowActivity? private var tracking: NSTrackingArea? private var monitor: Any? private var observers: [NSObjectProtocol] = [] override var isFlipped: Bool { true } override func updateTrackingAreas() { super.updateTrackingAreas() if let tracking { removeTrackingArea(tracking) } let area = NSTrackingArea( rect: .zero, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self) addTrackingArea(area) tracking = area } override func mouseEntered(with event: NSEvent) { report(event) } override func mouseMoved(with event: NSEvent) { report(event) } override func mouseExited(with event: NSEvent) { handlers?.exited() } private func report(_ event: NSEvent) { handlers?.moved(convert(event.locationInWindow, from: nil)) } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() stop() guard let window else { return } window.acceptsMouseMovedEvents = true // Tab is keyboard navigation starting: bring the controls to it. monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown]) { [weak self] event in nonisolated(unsafe) let event = event MainActor.assumeIsolated { guard let self, event.window === self.window else { return } if event.type == .leftMouseDown || event.keyCode == 48 { self.handlers?.activity() } } return event } let center = NotificationCenter.default observers = [ center.addObserver(forName: NSMenu.didBeginTrackingNotification, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.menuTracking(true) } }, center.addObserver(forName: NSMenu.didEndTrackingNotification, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.menuTracking(false) } }, center.addObserver( forName: NSWindow.willBeginSheetNotification, object: window, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.sheet(true) } }, center.addObserver( forName: NSWindow.didEndSheetNotification, object: window, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.sheet(false) } }, ] } func stop() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil observers.forEach(NotificationCenter.default.removeObserver) observers = [] } deinit { if let monitor { NSEvent.removeMonitor(monitor) } observers.forEach(NotificationCenter.default.removeObserver) } // A layer of bookkeeping, not of content: clicks go through it. override func hitTest(_ point: NSPoint) -> NSView? { nil } } } #endif