#if os(macOS) import AppKit import Combine import OSLog import SwiftUI /// The mini window's decisions, for a "sometimes" (2026-10-05, Paul: /// "Sometimes I can't click the mini window"): every press, what was /// under it, what it was taken as, and whether bringing the call forward /// worked. `log show --predicate 'subsystem == "net.amadan.oficina.calls" /// && category == "mini"' --info`. enum MiniLog { static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "mini") } extension View { /// Puts the call into a small floating window while the person is in /// another app, and back into this view when they return. Apply it to /// the root of the call window: that window is the one it hides. public func miniWindow(for model: CallsModel) -> some View { modifier(MiniWindowModifier(model: model)) } } private struct MiniWindowModifier: ViewModifier { @ObservedObject var model: CallsModel @StateObject private var controller = MiniWindowController() func body(content: Content) -> some View { content.background( WindowReader { window in controller.attach(model: model, mainWindow: window) }) } } extension NSScreen { /// The key a mini window position is remembered under on this /// display: its CGDirectDisplayID, which stays the same across /// launches and rearrangements for the same monitor. var callsDisplayKey: String { (deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) .map { "\($0.uint32Value)" } ?? "main" } } /// Watches the app come and go, asks MiniWindowPolicy what that means, /// and moves the windows it says to. It holds no rules of its own: /// where the window goes is MiniWindowCallMemory's, and how big it is /// MiniWindowRules'. @MainActor final class MiniWindowController: ObservableObject { private(set) var policy = MiniWindowPolicy() private let preferences: MiniWindowPreferences /// Collapsed or not, and ⌥-dropped positions: this call's alone. var memory = MiniWindowCallMemory() private weak var model: CallsModel? private weak var mainWindow: NSWindow? private var observers: [NSObjectProtocol] = [] private var joining: AnyCancellable? private(set) var panel: MiniWindowPanel? init(preferences: MiniWindowPreferences = MiniWindowPreferences()) { self.preferences = preferences let center = NotificationCenter.default let events: [(Notification.Name, @MainActor (MiniWindowController) -> Void)] = [ (NSApplication.didResignActiveNotification, { $0.resignedActive() }), (NSApplication.didBecomeActiveNotification, { $0.apply($0.policy.becameActive()) }), (NSApplication.willHideNotification, { $0.apply($0.policy.willHide()) }), (NSApplication.didUnhideNotification, { $0.policy.didUnhide() }), // A display unplugged, rearranged or changed resolution. (NSApplication.didChangeScreenParametersNotification, { $0.relayout() }), ] observers = events.map { name, handle in center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } handle(self) } } } // Another app coming forward can bring its own floating windows // over this one, and a Space change or a full-screen app can leave // it behind: put it back in front, taking clicks, each time. let workspace = NSWorkspace.shared.notificationCenter for name in [ NSWorkspace.activeSpaceDidChangeNotification, NSWorkspace.didActivateApplicationNotification, ] { observers.append( workspace.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in MainActor.assumeIsolated { self?.panel?.reassert(because: note.name.rawValue) } }) } } deinit { for observer in observers { NotificationCenter.default.removeObserver(observer) NSWorkspace.shared.notificationCenter.removeObserver(observer) } } func attach(model: CallsModel, mainWindow: NSWindow?) { if let mainWindow { self.mainWindow = mainWindow } guard self.model !== model else { return } self.model = model joining = model.$joining.removeDuplicates().sink { [weak self] joining in guard let self else { return } // A call starting or ending is a new call as far as the // mini window's memory goes. self.memory = MiniWindowCallMemory() if !joining { self.apply(self.policy.callEnded()) } } } private func resignedActive() { // One turn of the run loop first: a ⌘H or a quick switch straight // back should not flash a window up and down again. DispatchQueue.main.async { [weak self] in guard let self, !NSApp.isActive, !NSApp.isHidden else { return } let window = self.mainWindow self.apply( self.policy.resignedActive( .init( enabled: self.preferences.enabled, inCall: self.model?.joining ?? false, mainWindowVisible: window?.isVisible ?? false, mainWindowMiniaturized: window?.isMiniaturized ?? false, mainWindowFullScreen: window?.styleMask.contains(.fullScreen) ?? false))) } } func apply(_ action: MiniWindowPolicy.Action) { switch action { case .none: break case .show(let hideMain): guard let model else { return } // Never two: a panel left behind by a show without a restore would // sit over the new one, its clicks going to a window nobody sees. if let stale = panel { MiniLog.logger.info("show: dismissing a panel still up") stale.dismiss() } let panel = MiniWindowPanel( call: model.call, actions: MiniWindowActions( leave: { [weak model] in model?.leave() }, open: { [weak self] in self?.bringCallForward() }, moved: { [weak self] option in self?.dropped(option: option) }, resized: { [weak self] in self?.resized() }, collapse: { [weak self] in self?.collapse() }, expand: { [weak self] in self?.expand(pulled: false) }, tabReleased: { [weak self] in self?.tabReleased() })) self.panel = panel layout(panel, on: mainWindow?.screen ?? NSScreen.main) panel.reassert(because: "show") if hideMain, let mainWindow { Self.conceal(mainWindow, true) } case .restore(let activate): panel?.dismiss() panel = nil guard let mainWindow else { return } Self.conceal(mainWindow, false) // Forward unless the person came back by clicking another of this // app's windows, such as chat in its own window: that one keeps focus. if activate, NSApp.keyWindow == nil || NSApp.keyWindow === mainWindow { mainWindow.makeKeyAndOrderFront(nil) } } } /// A click on the mini window: the call, forward. Since macOS 14 an /// app's request to activate itself is a request the system may turn /// down, and a click in a non-activating panel does not activate the /// app by itself, so a refusal left the person clicking a window that /// did nothing. Asked for twice, the second time as the running /// application, and if the app is still not active the call window is /// put back and brought in front anyway, so a click always shows the /// call. Every step is logged. func bringCallForward() { MiniLog.logger.info( "open: activating (active=\(NSApp.isActive), frontmost=\(NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "none", privacy: .public))" ) if #available(macOS 14, *) { NSApp.activate() } else { NSApp.activate(ignoringOtherApps: true) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self, self.panel != nil else { return } if NSApp.isActive { return } MiniLog.logger.info("open: not active after 0.25 s; asking again as the running application") NSRunningApplication.current.activate(options: [.activateAllWindows]) DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self, self.panel != nil else { return } if NSApp.isActive { return } MiniLog.logger.error("open: activation refused twice; showing the call window in front regardless") self.apply(self.policy.becameActive()) self.mainWindow?.orderFrontRegardless() } } } /// Hides the call window by making it transparent and letting clicks /// through, not by ordering it out. SwiftUI treats an ordered-out /// `Window` as closed: the view's onDisappear runs, which leaves the /// call, and the app then quits for want of a window. Transparent, the /// window keeps its frame, its Space, its place in the stacking order /// and all of its state, so putting it back is exactly as it was. private static func conceal(_ window: NSWindow, _ hidden: Bool) { window.alphaValue = hidden ? 0 : 1 window.ignoresMouseEvents = hidden } /// Puts the window where this call's memory and the preferences say, /// at the remembered size, on `screen` — as a tab if it was collapsed. private func layout(_ panel: MiniWindowPanel, on screen: NSScreen?) { guard let screen else { return } let visible = screen.visibleFrame let size = MiniWindowRules.size(width: preferences.width, in: visible) let frame = memory.frame( size: size, in: visible, display: screen.callsDisplayKey, preferences: preferences) if memory.collapsed { panel.collapse(to: MiniWindowTab.dock(frame, in: visible).1, expanded: frame) } else { panel.expand(to: frame, animated: false) } } /// The displays changed under a window that is up: put it back where /// its placement says on whichever screen it is now on, clamped. private func relayout() { guard let panel else { return } layout(panel, on: panel.screen ?? NSScreen.main) } private var screen: NSScreen? { panel?.screen ?? NSScreen.main } /// A drag of the window ended. Where it settles is the call memory's /// decision: a corner, where it was dropped, or where it was dropped /// for this call only when ⌥ was held. private func dropped(option: Bool) { guard let panel, let screen else { return } let frame = memory.dropped( panel.frame, in: screen.visibleFrame, display: screen.callsDisplayKey, option: option, preferences: preferences) panel.expand(to: frame, animated: frame != panel.frame) } private func resized() { guard let panel, !panel.collapsed, let screen else { return } preferences.width = panel.frame.width let frame = memory.resized( panel.frame, in: screen.visibleFrame, display: screen.callsDisplayKey, preferences: preferences) panel.expand(to: frame, animated: frame != panel.frame) } private func collapse() { guard let panel, let screen else { return } memory.collapsed = true panel.collapse( to: MiniWindowTab.dock(panel.frame, in: screen.visibleFrame).1, expanded: panel.frame) } /// Back out of the tab: clicked, to where it was; pulled out, to where /// it was let go, settling as any other drag does. private func expand(pulled: Bool) { guard let panel, let screen else { return } memory.collapsed = false let visible = screen.visibleFrame let size = MiniWindowRules.size(width: panel.expandedFrame.width, in: visible) guard pulled else { panel.expand( to: MiniWindowRules.clamp( CGRect(origin: panel.expandedFrame.origin, size: size), into: visible), animated: false) return } let out = MiniWindowTab.expanded(from: panel.frame, size: size, in: visible) let frame = memory.dropped( out, in: visible, display: screen.callsDisplayKey, option: NSEvent.modifierFlags.contains(.option), preferences: preferences) panel.expand(to: frame, animated: false) } private func tabReleased() { guard let panel, let screen else { return } switch MiniWindowTab.release(panel.frame, in: screen.visibleFrame) { case .dock(_, let frame): panel.move(to: frame, animated: true) case .expand: expand(pulled: true) } } } /// What the window's controls and surface ask the controller to do. struct MiniWindowActions { var leave: () -> Void = {} var open: () -> Void = {} /// A drag of the expanded window ended; whether ⌥ was held. var moved: (_ option: Bool) -> Void = { _ in } var resized: () -> Void = {} var collapse: () -> Void = {} /// The tab was clicked. var expand: () -> Void = {} /// A drag of the tab ended. var tabReleased: () -> Void = {} } /// The floating window, expanded or collapsed to a tab. Non-activating, /// so clicking its controls does not drag the whole app forward; /// clicking anywhere else does, on purpose, because that is how you get /// back to the call. The tab is the same window at a different size, so /// it floats, follows Spaces and stays out of screen captures by the /// very same settings. final class MiniWindowPanel: NSPanel { let content: MiniWindowContent /// Where the expanded window was, while it is a tab. private(set) var expandedFrame: CGRect var collapsed: Bool { content.collapsed } init(call: Call, actions: MiniWindowActions) { content = MiniWindowContent(call: call) expandedFrame = CGRect(origin: .zero, size: MiniWindowRules.size) super.init( contentRect: NSRect(origin: .zero, size: MiniWindowRules.size), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) isReleasedWhenClosed = false isFloatingPanel = true level = .floating hidesOnDeactivate = false // Key only when something in it needs the keyboard (VoiceOver, or // Full Keyboard Access). Clicking Mute must not take the keyboard // from the app the person is typing in. becomesKeyOnlyIfNeeded = true // Every Space, and over a full-screen app, because the whole point // is that it follows the person to wherever they went. collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] // Never in anyone's screen capture, including this app's own shared // screen: nobody in the call needs to see themselves in a corner. sharingType = .none backgroundColor = .clear isOpaque = false hasShadow = true title = "Calls mini window" let hosting = MiniWindowHostingView( rootView: MiniCallView(content: content, actions: actions)) // The window's size is the controller's to set; SwiftUI's idea of // an ideal size must not pin it. hosting.sizingOptions = [] hosting.hover = content contentView = hosting } /// Borderless windows cannot be key by default, which would leave the /// window's controls out of reach of the keyboard altogether. override var canBecomeKey: Bool { true } /// In front, at its level, visible and taking the mouse: what a click /// on it needs, put back whenever something may have changed it. func reassert(because reason: String) { if ignoresMouseEvents || alphaValue < 1 || level != .floating { MiniLog.logger.info( "reassert (\(reason, privacy: .public)): mouse ignored=\(self.ignoresMouseEvents), alpha=\(self.alphaValue), level=\(self.level.rawValue)" ) } ignoresMouseEvents = false alphaValue = 1 level = .floating orderFrontRegardless() (contentView as? MiniWindowHostingView)?.syncHover() } /// Every press, logged with what it landed on, before AppKit sends it /// there. override func sendEvent(_ event: NSEvent) { if event.type == .leftMouseDown, let content = contentView { let hit = content.hitTest(content.convert(event.locationInWindow, from: nil)) MiniLog.logger.info( "mouse down at \(Int(event.locationInWindow.x)),\(Int(event.locationInWindow.y)) in \(Int(self.frame.width))x\(Int(self.frame.height)) (\(self.collapsed ? "tab" : "window", privacy: .public)) on \(hit.map { String(describing: type(of: $0)) } ?? "nothing", privacy: .public); app active=\(NSApp.isActive), key=\(self.isKeyWindow), clicks=\(event.clickCount)" ) } super.sendEvent(event) } func expand(to frame: CGRect, animated: Bool) { content.collapsed = false expandedFrame = frame move(to: frame, animated: animated) } func collapse(to tab: CGRect, expanded: CGRect) { expandedFrame = expanded content.hovering = false content.collapsed = true move(to: tab, animated: false) } func move(to frame: CGRect, animated: Bool) { if animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { NSAnimationContext.runAnimationGroup { context in context.duration = 0.2 animator().setFrame(frame, display: true) } } else { setFrame(frame, display: true) } } /// Takes the video out before the window goes, so every renderer this /// window added to a track is removed from it now and not whenever the /// hosting view happens to be released. The call window's own /// renderers are separate objects and are untouched. func dismiss() { content.call = nil contentView?.layoutSubtreeIfNeeded() contentView?.displayIfNeeded() orderOut(nil) contentView = nil close() } } @MainActor final class MiniWindowContent: ObservableObject { @Published var call: Call? @Published var hovering = false @Published var collapsed = false init(call: Call) { self.call = call } } /// Takes the first click even though the app is not active — otherwise /// the first click on Mute would only wake the window — and tracks the /// pointer the same way, since SwiftUI's hover follows the key window. private final class MiniWindowHostingView: NSHostingView { weak var hover: MiniWindowContent? private var tracking: NSTrackingArea? override func acceptsFirstMouse(for event: NSEvent?) -> 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 } /// A window that appears under the pointer, or moves under it, gets no /// mouseEntered, and its controls would stay hidden until the pointer /// left and came back: hover follows where the pointer is instead. func syncHover() { guard let window else { return } let inside = window.frame.contains(NSEvent.mouseLocation) if hover?.hovering != inside { hover?.hovering = inside } } override func mouseMoved(with event: NSEvent) { super.mouseMoved(with: event) if hover?.hovering == false { hover?.hovering = true } } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) hover?.hovering = true } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) hover?.hovering = false } } struct MiniCallView: View { @ObservedObject var content: MiniWindowContent let actions: MiniWindowActions @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Group { if content.collapsed, let call = content.call { MiniTabView(call: call, actions: actions) } else { ZStack { if let call = content.call { MiniCallStage( call: call, arrangement: call.arrangement, speakerNow: call.levels.speaker, showingControls: showingControls, actions: actions) CallToast(call: call).padding(8) .frame(maxHeight: .infinity, alignment: .top) } else { Color.black } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( MiniWindowSurface( mode: .window, click: { _ in actions.open() }, dropped: actions.moved, resized: actions.resized) ) .clipShape(RoundedRectangle(cornerRadius: 16)) } } .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: showingControls) .tint(CallsAppearance.accent) } /// VoiceOver users get the controls all the time: hover is not a thing /// a screen reader does. private var showingControls: Bool { content.hovering || NSWorkspace.shared.isVoiceOverEnabled } } private struct MiniCallStage: View { @ObservedObject var call: Call @ObservedObject var arrangement: CallArrangement @ObservedObject var speakerNow: SpeakerNow let showingControls: Bool let actions: MiniWindowActions /// What Speaker view's stage shows (0.4.0): what the person pinned, /// or else the newest shared screen, since a person who shares is /// asking to be looked at, or else whoever is speaking, or before /// anyone has, the first person in the viewer's order. Nobody else /// there: your own camera, since a window of nothing reads as a /// broken one. The same whichever layout the call window is in. private var subject: StageItem? { let item = arrangement.stage(active: speakerNow.peer, screens: call.screens) if case .camera(let peer) = item, peer == arrangement.me { return nil } return item } /// The people still connecting who are not the one on show. private var offStage: [String] { var shown: String? if case .camera(let peer) = subject { shown = peer } return call.connectingPeers.filter { $0 != shown }.sorted() } var body: some View { ZStack { Group { switch subject { case .screen(let peer): MacVideoTile( track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), screen: true) case .camera(let peer): MacVideoTile( track: call.remoteTracks[peer], label: call.name(of: peer), muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer), audio: call.tileAudio(peer)) case nil: MacVideoTile( track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, audio: call.selfAudio) } } // The tile is a picture, not a target: clicks and drags go through // to the surface underneath. .allowsHitTesting(false) VStack { HStack(spacing: 8) { // With the controls up, the Mute button says it; at the // smallest width there is no room for both. if call.muted, !showingControls { Label("Muted", systemImage: "mic.slash.fill").labelStyle(.iconOnly) .font(.callout.bold()).foregroundStyle(.white) .padding(8).callOverlaySurface(Circle()) .accessibilityLabel("Your microphone is muted") // A mark, not a control: a press on it is a press on the // window (MiniWindowGesture), not swallowed by SwiftUI. .allowsHitTesting(false) } Spacer() // Spec §15.24, for the people this window is not showing: the // one it shows says it on its own tile. Top right, clear of // the tile's Muted badge. ConnectingDot(names: offStage.map { call.name(of: $0) }) // Its names are in VoiceOver's label; a press goes through. .allowsHitTesting(false) if showingControls { Button("Collapse to a tab", glyph: "arrow.down.right.and.arrow.up.left") { actions.collapse() } .buttonStyle(CallActionStyle(color: Color(white: 0.3), emphasized: true)) .help("Collapse to a tab at the edge of the screen") Button( call.muted ? "Unmute microphone" : "Mute microphone", glyph: call.muted ? "mic.slash.fill" : "mic.fill" ) { call.mute() } .buttonStyle( CallActionStyle( color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, emphasized: true) ) .help(call.muted ? "Unmute microphone" : "Mute microphone") Button( "Leave meeting", glyph: "phone.down.fill", role: .destructive, action: actions.leave ) .buttonStyle(CallActionStyle(color: .red, emphasized: true)) .help("Leave meeting") } } Spacer() }.padding(10) } } } /// The mini window collapsed: whether your microphone is on, and who /// the call is with. Click it to open the mini window again, drag it out /// to open it there, double-click it for the whole call. /// /// A small waveform shows while someone else is speaking, with the /// speaking glow behind it following the loudest of them, from the /// receive meters (spec §15.20), which read five times a second — quick /// enough to follow speech, where the five-second audio readout was not. struct MiniTabView: View { @ObservedObject var call: Call let actions: MiniWindowActions private var others: [String] { let me = call.state?.peer return (call.state?.peers ?? []).filter { $0.peer != me }.map(\.name) } private var caption: String { MiniWindowTab.caption(others: others) } var body: some View { HStack(spacing: 8) { Image(systemName: call.muted ? "mic.slash.fill" : "mic.fill") .font(.callout.bold()) .foregroundStyle(.white) .frame(width: 26, height: 26) .background( call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, in: Circle()) // Spec §15.24: someone is still connecting and cannot hear you // yet. A dot only — the tab has no room for words; its // accessibility label and the window, opened, say who. .overlay(alignment: .topTrailing) { if !call.connectingPeers.isEmpty { Circle().fill(ConnectingDot.colour).frame(width: 8, height: 8) .overlay(Circle().strokeBorder(Color.black.opacity(0.35), lineWidth: 1)) .offset(x: 2, y: -2) .help(call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) }.joined(separator: "\n")) } } Text(caption).font(.callout.weight(.medium)).lineLimit(1).truncationMode(.tail) .foregroundStyle(.white) Spacer(minLength: 0) SpeakingGlyph(speaking: call.levels.anyoneSpeaking, glow: call.levels.othersGlow) } // Nothing in the tab is a control of its own: every press on it, // on the microphone, the names or the waveform included, is the // surface's, so a click always opens and a drag always moves. Until // 2026-10-05 a press on the names or a mark went to SwiftUI, which // had nothing to do with it, and was lost (CallsInterop // --mini-clicks). .allowsHitTesting(false) .padding(.horizontal, 7) .frame(width: MiniWindowTab.size.width, height: MiniWindowTab.size.height) .background( MiniWindowSurface( mode: .tab, click: { count in count >= 2 ? actions.open() : actions.expand() }, dropped: { _ in actions.tabReleased() }, resized: {}) ) // The call's glass, borderless, as over the picture. .callOverlaySurface(Capsule()) .clipShape(Capsule()) .accessibilityElement(children: .ignore) .accessibilityAddTraits(.isButton) .accessibilityLabel( (["Calls, \(caption)"] + call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) }) .joined(separator: ". ")) .accessibilityValue(call.muted ? "Microphone muted" : "Microphone on") .accessibilityHint("Shows the mini window.") .accessibilityAction { actions.expand() } .accessibilityAction(named: "Return to the call window") { actions.open() } .accessibilityAction(named: call.muted ? "Unmute microphone" : "Mute microphone") { call.mute() } .accessibilityAction(named: "Leave meeting") { actions.leave() } } } /// The mini window's whole say on spec §15.24: a small ear on the glass /// while someone it is not showing is connecting and cannot hear you yet, /// with their names in its tooltip and for VoiceOver. Nothing at all /// otherwise. struct ConnectingDot: View { /// A warm amber, light enough to tell from the muted microphone's /// brown on the tab. static let colour = Color(red: 1, green: 0.78, blue: 0.42) let names: [String] var body: some View { if !names.isEmpty { let words = names.map(ConnectingWords.connecting).joined(separator: "\n") Image(systemName: "ear") .font(.callout.bold()).foregroundStyle(.white) .frame(width: 32, height: 32) .callOverlaySurface(Circle(), rim: false) .overlay(alignment: .topTrailing) { Circle().fill(Self.colour).frame(width: 7, height: 7) } .help(words) .accessibilityElement(children: .ignore) .accessibilityLabel(words) .accessibilityIdentifier("connectingDot") } } } /// The window's background: a click, a drag that moves the window, and /// — for the expanded window — a drag from an edge or corner that /// resizes it. Handled here rather than with isMovableByWindowBackground /// so the three can be told apart. private struct MiniWindowSurface: NSViewRepresentable { enum Mode { case window, tab } let mode: Mode /// A click, with its click count. let click: (Int) -> Void /// A move ended; whether ⌥ was held when it did. let dropped: (Bool) -> Void let resized: () -> Void func makeNSView(context: Context) -> SurfaceView { SurfaceView() } func updateNSView(_ view: SurfaceView, context: Context) { view.mode = mode view.click = click view.dropped = dropped view.resized = resized } final class SurfaceView: NSView { var mode = Mode.window var click: (Int) -> Void = { _ in } var dropped: (Bool) -> Void = { _ in } var resized: () -> Void = {} private var tracking: NSTrackingArea? override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } // The tab is described by its SwiftUI view, which has the actions. override func isAccessibilityElement() -> Bool { mode == .window } override func accessibilityRole() -> NSAccessibility.Role? { .button } override func accessibilityLabel() -> String? { "Return to the call window" } override func accessibilityPerformPress() -> Bool { click(1) return true } private func handle(at point: NSPoint) -> MiniWindowResize.Handle { guard mode == .window else { return [] } return MiniWindowResize.handle(at: point, in: bounds.size) } // The resize cursor at the edges, whether or not Calls is the // active app — it usually is not, while this window is up. override func updateTrackingAreas() { super.updateTrackingAreas() if let tracking { removeTrackingArea(tracking) } let area = NSTrackingArea( rect: .zero, options: [.mouseMoved, .mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self) addTrackingArea(area) tracking = area } override func mouseMoved(with event: NSEvent) { Self.cursor(for: handle(at: convert(event.locationInWindow, from: nil))).set() } override func mouseExited(with event: NSEvent) { NSCursor.arrow.set() } private static func cursor(for handle: MiniWindowResize.Handle) -> NSCursor { let horizontal = !handle.isDisjoint(with: [.left, .right]) let vertical = !handle.isDisjoint(with: [.top, .bottom]) if #available(macOS 15, *) { let position: NSCursor.FrameResizePosition? = switch (handle.contains(.top), handle.contains(.bottom), handle.contains(.left), handle.contains(.right)) { case (true, _, true, _): .topLeft case (true, _, _, true): .topRight case (_, true, true, _): .bottomLeft case (_, true, _, true): .bottomRight case (true, _, _, _): .top case (_, true, _, _): .bottom case (_, _, true, _): .left case (_, _, _, true): .right default: nil } if let position { return .frameResize(position: position, directions: .all) } return .arrow } switch (horizontal, vertical) { case (true, false): return .resizeLeftRight case (false, true): return .resizeUpDown case (true, true): return .crosshair default: return .arrow } } override func mouseDown(with event: NSEvent) { guard let window else { return } let surface: MiniWindowGesture.Surface = mode == .window ? .window : .tab let start = window.convertPoint(toScreen: event.locationInWindow) let startFrame = window.frame let grabbed = MiniWindowGesture.grab( at: convert(event.locationInWindow, from: nil), in: bounds.size, surface: surface) let visible = window.screen?.visibleFrame ?? startFrame var dragging = false while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { let now = Self.screenLocation(of: next) let dx = now.x - start.x let dy = now.y - start.y if !dragging, MiniWindowGesture.dragged(dx: dx, dy: dy) { dragging = true } if dragging { if grabbed.isEmpty { window.setFrameOrigin(NSPoint(x: startFrame.minX + dx, y: startFrame.minY + dy)) } else { window.setFrame( MiniWindowResize.frame( from: startFrame, dragging: grabbed, dx: dx, dy: dy, in: visible), display: true) } } if next.type == .leftMouseUp { break } } let outcome = MiniWindowGesture.outcome( grabbed: grabbed, dragged: dragging, clickCount: event.clickCount, option: NSEvent.modifierFlags.contains(.option) || event.modifierFlags.contains(.option)) MiniLog.logger.info("\(surface.rawValue, privacy: .public) press: \(String(describing: outcome), privacy: .public)") switch outcome { case .click(let count): click(count) case .move(let option): dropped(option) case .resize: resized() } } /// Where the pointer is for an event of a drag. The window moves /// under the pointer as it is dragged, so a real event's position in /// the window is stale by the time it is read: the pointer's own /// location is used. An event made with no window (CallsInterop's /// --mini-clicks) carries screen coordinates and is read as it is. static func screenLocation(of event: NSEvent) -> CGPoint { event.windowNumber == 0 && event.window == nil ? event.locationInWindow : NSEvent.mouseLocation } } } /// The mini window's two faces, for CallsInterop's offline snapshots. /// The views themselves stay internal; this is the only way in. @MainActor public enum MiniWindowSnapshot { public static func window(call: Call, hovering: Bool) -> some View { let content = MiniWindowContent(call: call) content.hovering = hovering return MiniCallView(content: content, actions: MiniWindowActions()) } public static func tab(call: Call) -> some View { let content = MiniWindowContent(call: call) content.collapsed = true return MiniCallView(content: content, actions: MiniWindowActions()) } } /// Finds the NSWindow a SwiftUI view is in, once it is in one. private struct WindowReader: NSViewRepresentable { let found: (NSWindow?) -> Void func makeNSView(context: Context) -> ReaderView { let view = ReaderView() view.found = found return view } func updateNSView(_ view: ReaderView, context: Context) { view.found = found if let window = view.window { found(window) } } final class ReaderView: NSView { var found: (NSWindow?) -> Void = { _ in } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if let window { found(window) } } } } #endif