| 1 | import Foundation |
| 2 | |
| 3 | /// What has the keyboard in a window when Space is pressed. |
| 4 | public enum KeyboardFocus: Equatable, Sendable { |
| 5 | /// Nothing in particular: the window itself, or its root view. |
| 6 | case none |
| 7 | /// A text field or text view, where Space is a space. |
| 8 | case text |
| 9 | /// A button, picker or any other control, where Space is the |
| 10 | /// control's own activation. Anything not known to be one of the |
| 11 | /// other two counts as this: an unrecognised focused thing keeps its |
| 12 | /// Space rather than losing it to the microphone. |
| 13 | case control |
| 14 | } |
| 15 | |
| 16 | /// Space toggles the microphone during a call, the way it does in most |
| 17 | /// calling apps — but only when Space means nothing else. The rules are |
| 18 | /// here, apart from the key monitor that applies them, so they can be |
| 19 | /// held by tests without a window or a keyboard. |
| 20 | public enum SpaceToMute { |
| 21 | public enum Action: Equatable, Sendable { |
| 22 | /// Toggle mute, and consume the key. |
| 23 | case toggle |
| 24 | /// Consume the key and do nothing: a held Space's key repeats, which |
| 25 | /// would otherwise flicker the microphone or beep. |
| 26 | case swallow |
| 27 | /// Not ours: let the key go on to whatever has focus. |
| 28 | case pass |
| 29 | } |
| 30 | |
| 31 | /// The virtual key code of the Space bar on every Mac keyboard layout. |
| 32 | public static let keyCode: UInt16 = 49 |
| 33 | |
| 34 | /// - Parameters: |
| 35 | /// - isSpace: the key is the Space bar. |
| 36 | /// - isRepeat: the event is the key repeating while held. |
| 37 | /// - modified: ⌘, ⌃ or ⌥ (or ⇧) is held. ⇧Space and the rest are |
| 38 | /// shortcuts of their own, never the bare Space this is. |
| 39 | /// - focus: what has the keyboard. |
| 40 | public static func action( |
| 41 | isSpace: Bool, isRepeat: Bool, modified: Bool, focus: KeyboardFocus |
| 42 | ) -> Action { |
| 43 | guard isSpace, !modified, focus == .none else { return .pass } |
| 44 | return isRepeat ? .swallow : .toggle |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | #if os(macOS) |
| 49 | import AppKit |
| 50 | import SwiftUI |
| 51 | |
| 52 | extension SpaceToMute { |
| 53 | /// What has the keyboard, from AppKit's first responder. A SwiftUI |
| 54 | /// TextField edits through the window's field editor, an NSText; a |
| 55 | /// SwiftUI Button focused by Full Keyboard Access is a private proxy |
| 56 | /// view inside the hosting view, which is why anything unrecognised |
| 57 | /// is a control. The window, nothing, or the root view is nobody. |
| 58 | public static func focus(of responder: NSResponder?, contentView: NSView?) -> KeyboardFocus { |
| 59 | switch responder { |
| 60 | case nil: return .none |
| 61 | case is NSWindow: return .none |
| 62 | case let view as NSView where view === contentView: return .none |
| 63 | case is NSText: return .text |
| 64 | case let field as NSTextField where field.isEditable: return .text |
| 65 | default: return .control |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// The decision for a key event arriving in `window`. |
| 70 | @MainActor static func action(for event: NSEvent, in window: NSWindow) -> Action { |
| 71 | guard event.type == .keyDown else { return .pass } |
| 72 | let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) |
| 73 | .subtracting([.capsLock, .numericPad, .function]) |
| 74 | return action( |
| 75 | isSpace: event.keyCode == keyCode, isRepeat: event.isARepeat, modified: !modifiers.isEmpty, |
| 76 | focus: focus(of: window.firstResponder, contentView: window.contentView)) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | extension View { |
| 81 | /// Space toggles `call`'s microphone while this view's window — or the |
| 82 | /// mini window standing in for it — is the key window. Apply it to the |
| 83 | /// call's own view. |
| 84 | func spaceToMute(_ call: Call) -> some View { |
| 85 | background(SpaceToMuteMonitor(call: call).frame(width: 0, height: 0).accessibilityHidden(true)) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | /// Holds a local key monitor for as long as the call's view is in a |
| 90 | /// window. Local monitors see every key event the app receives, so the |
| 91 | /// monitor checks which window the event is for before anything else. |
| 92 | private struct SpaceToMuteMonitor: NSViewRepresentable { |
| 93 | let call: Call |
| 94 | |
| 95 | func makeNSView(context: Context) -> MonitorView { MonitorView() } |
| 96 | func updateNSView(_ view: MonitorView, context: Context) { view.call = call } |
| 97 | |
| 98 | final class MonitorView: NSView { |
| 99 | weak var call: Call? |
| 100 | private var monitor: Any? |
| 101 | |
| 102 | override func viewDidMoveToWindow() { |
| 103 | super.viewDidMoveToWindow() |
| 104 | if let monitor { NSEvent.removeMonitor(monitor) } |
| 105 | monitor = nil |
| 106 | guard window != nil else { return } |
| 107 | monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in |
| 108 | // Local monitors run on the main thread, with the event the app |
| 109 | // is about to dispatch there. |
| 110 | nonisolated(unsafe) let event = event |
| 111 | let consumed = MainActor.assumeIsolated { () -> Bool in |
| 112 | guard let self, let host = self.window, let call = self.call, |
| 113 | let target = event.window, target === host || target is MiniWindowPanel |
| 114 | else { return false } |
| 115 | switch SpaceToMute.action(for: event, in: target) { |
| 116 | case .pass: return false |
| 117 | case .swallow: return true |
| 118 | case .toggle: |
| 119 | call.mute() |
| 120 | return true |
| 121 | } |
| 122 | } |
| 123 | return consumed ? nil : event |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | deinit { |
| 128 | if let monitor { NSEvent.removeMonitor(monitor) } |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | #endif |
| 133 | |