import AppKit import KnotchyCore import Observation import SwiftUI final class NotchPanel: NSPanel { init() { super.init(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) level = NSWindow.Level(rawValue: NSWindow.Level.mainMenu.rawValue + 3) collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle] isOpaque = false backgroundColor = .clear hasShadow = false becomesKeyOnlyIfNeeded = true isMovable = false isReleasedWhenClosed = false hidesOnDeactivate = false } override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } } enum Presentation: Equatable { case hidden case compact case banner case panel } @MainActor @Observable final class NotchChrome { static let earWidth: CGFloat = 64 static let bannerWidth: CGFloat = 320 static let bannerRowHeight: CGFloat = 34 var presentation: Presentation = .hidden /// Lags `presentation` while retracting so the content shrinks into the notch with the shape. var content: Presentation = .hidden var reveal: CGFloat = 0 var state: SessionState = .idle var notice: Notice? var notchSize = CGSize(width: 200, height: 32) var hasNotch = true var panelSize = CGSize(width: 652, height: 472) var panelOffsetX: CGFloat = 0 var routeLabel: String? var bodySize: CGSize { Self.bodySize(presentation, notch: notchSize, panel: panelSize, hasNotch: hasNotch) } var topRadius: CGFloat { Self.topRadius(presentation) } var bottomRadius: CGFloat { Self.bottomRadius(presentation) } var offsetX: CGFloat { presentation == .panel ? panelOffsetX : 0 } static func bodySize(_ presentation: Presentation, notch: CGSize, panel: CGSize, hasNotch: Bool) -> CGSize { switch presentation { case .hidden: CGSize(width: notch.width, height: hasNotch ? notch.height : 0) case .compact: CGSize(width: notch.width + 2 * earWidth, height: notch.height) case .banner: CGSize(width: max(notch.width + 2 * earWidth, bannerWidth), height: notch.height + bannerRowHeight) case .panel: panel } } static func topRadius(_ presentation: Presentation) -> CGFloat { switch presentation { case .hidden: 0 case .compact: 6 case .banner: 8 case .panel: 10 } } static func bottomRadius(_ presentation: Presentation) -> CGFloat { switch presentation { case .hidden: 10 case .compact: 12 case .banner: 18 case .panel: 22 } } } /// Tracks the shape, not the window, which is larger while the shape animates. final class TrackingHostingView: NSHostingView { var onEnter: () -> Void = {} var onExit: () -> Void = {} var trackingRect: CGRect = .zero { didSet { if trackingRect != oldValue { updateTrackingAreas() } } } /// Remove only this one; NSHostingView's own areas drive SwiftUI hover effects. private var shapeArea: NSTrackingArea? override func updateTrackingAreas() { super.updateTrackingAreas() if let shapeArea { removeTrackingArea(shapeArea) } shapeArea = nil guard !trackingRect.isEmpty else { return } let area = NSTrackingArea(rect: trackingRect, options: [.mouseEnteredAndExited, .activeAlways], owner: self) addTrackingArea(area) shapeArea = area } override func mouseEntered(with event: NSEvent) { if event.trackingArea === shapeArea { onEnter() } else { super.mouseEntered(with: event) } } override func mouseExited(with event: NSEvent) { if event.trackingArea === shapeArea { onExit() } else { super.mouseExited(with: event) } } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } } @MainActor final class NotchWindowController { private static let shadowPadding: CGFloat = 36 private let controller: SessionController private let chrome = NotchChrome() private let panel = NotchPanel() private let host: TrackingHostingView private var screen: NSScreen? private var anchor = CGPoint.zero private var clickMonitor: Any? private var lastState: SessionState = .idle /// A finished animation only tidies up if no newer one has started. private var generation = 0 private var trackingExtent: Extent? private let currentRouteLabel: () -> String? private var sessionRouteLabel: String? init(controller: SessionController, routeLabel: @escaping () -> String? = { nil }) { self.controller = controller currentRouteLabel = routeLabel host = TrackingHostingView(rootView: NotchRootView(controller: controller, chrome: chrome)) host.sizingOptions = [] host.onEnter = { [weak self] in self?.pointerEntered() } host.onExit = { [weak self] in self?.pointerExited() } panel.contentView = host // Global mouse monitors need no Accessibility permission. clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { _ in MainActor.assumeIsolated { controller.clickedOutside() } } NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } self.screen = nil self.panel.orderOut(nil) self.update() } } observe() update() } func hide() { panel.orderOut(nil) } /// So crossing the notch on the way to the menu bar doesn't open the folded pill. private static let hoverIntent: Duration = .milliseconds(150) private var pendingEnter: Task? private func pointerEntered() { pendingEnter?.cancel() guard chrome.presentation == .compact else { controller.pointerEntered() return } pendingEnter = Task { [weak self] in try? await Task.sleep(for: Self.hoverIntent) guard !Task.isCancelled else { return } self?.controller.pointerEntered() } } private func pointerExited() { pendingEnter?.cancel() pendingEnter = nil controller.pointerExited() } /// A key non-activating panel keeps the keyboard until it leaves the screen; `resignKey()` only notifies. private func relinquishKey() { guard panel.isKeyWindow else { return } panel.orderOut(nil) panel.orderFrontRegardless() } private func observe() { withObservationTracking { _ = controller.state _ = controller.notice _ = controller.setupFolded } onChange: { [weak self] in Task { @MainActor in self?.update() self?.observe() } } } static func presentation(for state: SessionState, notice: Notice?, setupFolded: Bool) -> Presentation { switch state { case .settingUp where setupFolded: .compact case .idle: notice == nil ? .hidden : .banner case .starting(_, folded: true), .live(.collapsed): .compact case .destroying, .destroyed, .failed(.ended), .failed(.destroyStuck): .banner case .starting(_, folded: false), .live(.peek), .live(.expanded), .failed(.start), .setupRequired, .settingUp: .panel } } private func update() { let state = controller.state let notice = controller.notice defer { lastState = state } let target = Self.presentation(for: state, notice: notice, setupFolded: controller.setupFolded) guard target != .hidden else { retract() return } if screen == nil || (state == .starting(.booting, folded: false) && lastState != state) { let next = Self.screenUnderPointer() if next != screen { panel.orderOut(nil) } screen = next } guard let screen else { return } // The route is chosen as the VM boots, so read it once connecting. if case .starting(.booting, _) = state, lastState != state { sessionRouteLabel = nil } if case .starting(.connecting, _) = state, lastState != state { sessionRouteLabel = currentRouteLabel() } let routeLabel = sessionRouteLabel let info = ScreenInfo(screen) let large: Bool switch state { case .live(.expanded): large = true case .starting(_, folded: false): large = controller.opensExpanded default: large = false } let layout = NotchGeometry.layout(for: info, mode: large ? .expanded : .peek) let notchSize = layout.notch.size let panelSize = layout.panel.size let panelOffsetX = layout.panel.midX - layout.notch.midX anchor = CGPoint(x: layout.notch.midX, y: info.frame.maxY) generation += 1 let current = generation let apply = { [chrome] in chrome.presentation = target chrome.content = target chrome.reveal = 1 chrome.state = state chrome.notice = notice chrome.notchSize = notchSize chrome.panelSize = panelSize chrome.panelOffsetX = panelOffsetX chrome.hasNotch = info.hasNotch chrome.routeLabel = routeLabel } let targetExtent = Self.extent(target, notch: notchSize, panel: panelSize, offset: panelOffsetX, hasNotch: info.hasNotch) setTracking(targetExtent) if !panel.isVisible { var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { apply() chrome.presentation = .hidden chrome.reveal = 0 } let hiddenExtent = Self.extent(.hidden, notch: notchSize, panel: panelSize, offset: 0, hasNotch: info.hasNotch) fitWindow(hiddenExtent, targetExtent) panel.orderFrontRegardless() // Draw while invisible and start ~2 frames later, or the first draw (fonts, symbols) stalls the animation. withTransaction(transaction) { host.layoutSubtreeIfNeeded() } panel.displayIfNeeded() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(35)) { [weak self] in guard let self, self.generation == current else { return } self.animate(from: .hidden, to: target, apply: apply, generation: current, extent: targetExtent) } } else { let from = chrome.presentation let currentExtent = Self.extent(from, notch: chrome.notchSize, panel: chrome.panelSize, offset: chrome.offsetX, hasNotch: chrome.hasNotch) fitWindow(currentExtent, targetExtent) animate(from: from, to: target, apply: apply, generation: current, extent: targetExtent) } // Only a click into the page (VideoView) makes the panel key; opening never does. if target != .panel { relinquishKey() } } private func animate(from: Presentation, to target: Presentation, apply: @escaping () -> Void, generation current: Int, extent: Extent) { withAnimation(Self.animation(from: from, to: target), completionCriteria: .removed) { apply() } completion: { [weak self] in guard let self, self.generation == current else { return } self.fitWindow(extent) } } private func retract() { guard panel.isVisible else { screen = nil return } generation += 1 let current = generation setTracking(nil) relinquishKey() withAnimation(Motion.hide, completionCriteria: .removed) { chrome.presentation = .hidden chrome.reveal = 0 } completion: { [weak self] in guard let self, self.generation == current else { return } self.panel.orderOut(nil) self.screen = nil var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { self.chrome.content = .hidden } } } private static func animation(from: Presentation, to target: Presentation) -> Animation { switch (from, target) { case (.panel, .panel): Motion.resize case (_, .panel): Motion.open case (.panel, _): Motion.fold default: Motion.small } } // MARK: - Geometry struct Extent { /// Reach on the wider side of the notch's centre line. var halfWidth: CGFloat var height: CGFloat var offset: CGFloat = 0 var width: CGFloat = 0 } static func extent(_ presentation: Presentation, notch: CGSize, panel: CGSize, offset: CGFloat, hasNotch: Bool) -> Extent { let body = NotchChrome.bodySize(presentation, notch: notch, panel: panel, hasNotch: hasNotch) let width = body.width + 2 * NotchChrome.topRadius(presentation) let shift = presentation == .panel ? offset : 0 return Extent(halfWidth: width / 2 + abs(shift), height: body.height, offset: shift, width: width) } private func fitWindow(_ extents: Extent...) { let half = (extents.map(\.halfWidth).max() ?? 0) + Self.shadowPadding let height = (extents.map(\.height).max() ?? 0) + Self.shadowPadding let frame = CGRect(x: (anchor.x - half).rounded(), y: anchor.y - height.rounded(.up), width: (2 * half).rounded(.up), height: height.rounded(.up)) if panel.frame != frame { panel.setFrame(frame, display: false) // Lay out outside any animation, or the shape animates across from the old window position. var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { host.layoutSubtreeIfNeeded() } } refreshTracking() } private func setTracking(_ extent: Extent?) { trackingExtent = extent refreshTracking() } private func refreshTracking() { guard let extent = trackingExtent else { host.trackingRect = .zero return } let bounds = host.bounds let y = host.isFlipped ? bounds.minY : bounds.maxY - extent.height host.trackingRect = CGRect(x: bounds.midX + extent.offset - extent.width / 2, y: y, width: extent.width, height: extent.height) } #if DEBUG func snapshot(to directory: URL, name: String) { guard panel.isVisible else { FileHandle.standardError.write(Data("snapshot \(name): window hidden\n".utf8)) return } DebugSnapshot.write(host, to: directory.appendingPathComponent("\(name).png")) } #endif private static func screenUnderPointer() -> NSScreen? { let pointer = NSEvent.mouseLocation return NSScreen.screens.first { NSMouseInRect(pointer, $0.frame, false) } ?? NSScreen.main } } extension ScreenInfo { /// The only code that reads `NSScreen`. @MainActor init(_ screen: NSScreen) { func global(_ rect: CGRect?) -> CGRect { guard let rect, !rect.isEmpty else { return .zero } // The auxiliary areas are documented in screen coordinates; make them global. return screen.frame.contains(rect.origin) ? rect : rect.offsetBy(dx: screen.frame.minX, dy: screen.frame.minY) } self.init(frame: screen.frame, visibleFrame: screen.visibleFrame, safeAreaTop: screen.safeAreaInsets.top, auxLeft: global(screen.auxiliaryTopLeftArea), auxRight: global(screen.auxiliaryTopRightArea), menuBarThickness: NSStatusBar.system.thickness) } }