Merge branch 'feat/mini-window-and-space-mute' into main
Resizable mini window, collapse to a tab, free placement (with Option-drag one-offs), and Space to mute on Mac, web and iOS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 files changed,
+1577
−69
Makefile+1 −0internal/calls/static/room.js+56 −1internal/callstest/browser_test.go+1 −0native/README.md+26 −2native/apple/Sources/CallsInterop/LobbySnapshots.swift+1 −0native/apple/Sources/CallsInterop/MiniWindowSnapshots.swift+32 −0native/apple/Sources/CallsKit/CallsSettingsView.swift+32 −2native/apple/Sources/CallsKit/MacCallView.swift+3 −0native/apple/Sources/CallsKit/MiniWindow.swift+354 −55native/apple/Sources/CallsKit/MiniWindowRules.swift+334 −0native/apple/Sources/CallsKit/SpaceToMute.swift+132 −0native/apple/Sources/CallsMac/CallsMac.swift+18 −0native/apple/Tests/CallsKitTests/MiniWindowTests.swift+384 −9native/ios/Sources/CallsPhone.swift+5 −0test/browser/call.mjs+81 −0test/browser/room-space.test.mjs+117 −0
CI passed — run details
diff --git a/Makefile b/Makefile| index 7e267e5..04c8cb5 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -148,6 +148,7 @@ browser: |
| node --test test/browser/chat.test.mjs |
| node --test test/browser/room-share.test.mjs |
| node --test test/browser/room-mute.test.mjs |
| + node --test test/browser/room-space.test.mjs |
| node --test test/browser/room-lane.test.mjs |
| node --test test/browser/media.test.mjs |
| node --test test/browser/audio.test.mjs |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index dce1300..258b39f 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -4134,6 +4134,40 @@ |
| return parts.join(' '); |
| } |
| + // spaceKeyAction is what a keydown means for Space-to-mute: 'toggle', |
| + // 'swallow' (the same key repeating while held), or '' when it is not |
| + // ours — another key, a modified Space (those are shortcuts of their |
| + // own), text being composed by an input method, or focus on something |
| + // for which Space is typing or activation. Pure, and lifted by name in |
| + // the node test with spaceBelongsTo. |
| + function spaceKeyAction(ev, target) { |
| + if (!ev || (ev.key !== ' ' && ev.code !== 'Space')) return ''; |
| + if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey || ev.isComposing) return ''; |
| + if (spaceBelongsTo(target)) return ''; |
| + return ev.repeat ? 'swallow' : 'toggle'; |
| + } |
| + |
| + // spaceBelongsTo says whether Space already means something to the |
| + // focused element: text to type into, a control it activates (buttons, |
| + // checkboxes, summaries, links, anything given such a role), a select |
| + // it opens, or a media element's own controls. Unknown is not |
| + // claimed: a tile, the page body, a heading. |
| + function spaceBelongsTo(el) { |
| + if (!el || !el.tagName) return false; |
| + if (el.isContentEditable) return true; |
| + const tag = String(el.tagName).toUpperCase(); |
| + if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'SUMMARY', 'OPTION', 'IFRAME'].includes(tag)) return true; |
| + const has = (name) => !!(el.hasAttribute && el.hasAttribute(name)); |
| + if (tag === 'A' && has('href')) return true; |
| + if ((tag === 'VIDEO' || tag === 'AUDIO') && has('controls')) return true; |
| + const role = String((el.getAttribute && el.getAttribute('role')) || '').trim().toLowerCase(); |
| + return [ |
| + 'button', 'link', 'checkbox', 'switch', 'radio', 'tab', 'menuitem', 'menuitemcheckbox', |
| + 'menuitemradio', 'option', 'slider', 'spinbutton', 'combobox', 'textbox', 'searchbox', |
| + 'listbox', 'treeitem', 'gridcell', 'scrollbar', |
| + ].includes(role); |
| + } |
| + |
| function announceMedia(text) { |
| if (!text || !mediaLive) return; |
| mediaLive.textContent = text; |
| @@ -4603,10 +4637,31 @@ |
| // Controls (§3.4) |
| // --------------------------------------------------------------- |
| - muteButton.addEventListener('click', () => { |
| + // The one way the microphone is toggled, by the button and by Space |
| + // alike, so the tile marks and the `media` report to peers follow |
| + // either (applyToggles). A toggle from the keyboard is also said out |
| + // loud: the button's aria-pressed is announced only to someone whose |
| + // focus is on the button, and Space works precisely when it is not. |
| + function toggleMute(fromKeyboard) { |
| prefs.muted = !prefs.muted; |
| savePrefs(); |
| applyToggles(); |
| + if (fromKeyboard) announceMedia(prefs.muted ? 'Microphone muted.' : 'Microphone on.'); |
| + } |
| + |
| + muteButton.addEventListener('click', () => toggleMute(false)); |
| + |
| + // Space toggles mute, as it does in most calling apps — but only when |
| + // Space means nothing else. spaceKeyAction decides: 'toggle', 'swallow' |
| + // for a held key's repeats (handled, so they neither flicker the |
| + // microphone nor scroll the page), or '' for a key that is not ours. |
| + // Only a handled key is prevented. Push-to-talk (holding Space while |
| + // muted to speak) would start here and is deliberately not built. |
| + document.addEventListener('keydown', (ev) => { |
| + const action = spaceKeyAction(ev, ev.target); |
| + if (!action || muteButton.disabled || state.stopped) return; |
| + ev.preventDefault(); |
| + if (action === 'toggle') toggleMute(true); |
| }); |
| cameraButton.addEventListener('click', () => { |
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go| index 33b92d8..c7de78b 100644 |
| --- a/internal/callstest/browser_test.go |
| +++ b/internal/callstest/browser_test.go |
| @@ -85,6 +85,7 @@ var scenarios = []string{ |
| "devices", |
| "call", |
| "mute", |
| + "spacemute", |
| "switching", |
| "share", |
| "mutualshare", |
diff --git a/native/README.md b/native/README.md| index 8c39352..4ced9a7 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -395,8 +395,32 @@ On Mac, **switching to another app during a call** puts the call in a small |
| floating window, bottom right by default, and coming back to Calls restores the |
| call window as it was. It is the first setting in **Calls → Settings…**, on by |
| default, with the corner beside it; dragging the window snaps it to the nearest |
| -corner and remembers that one. Clicking the window brings Calls back; hovering |
| -shows Mute and Leave. It shows what the call window's stage would show by |
| +corner and remembers that one. **Mini window placement** can instead be |
| +**Anywhere I put it**: the window stays exactly where it is dropped, remembered |
| +per display (by display ID, as an offset from that display's visible frame, so |
| +rearranging displays does not lose it) and clamped back on screen when a display |
| +changes. Holding **⌥ Option** while dragging places it freely even when snapping |
| +to corners, for the rest of that call only. It resizes from its edges and |
| +corners at 16:9, from 200 pt wide up to 40% of the screen's width, and the size |
| +is remembered; snapping keeps it (`MiniWindowResize`, done by the window itself |
| +rather than AppKit's `.resizable`). Clicking the window brings Calls back; |
| +hovering shows Collapse, Mute and Leave. **Collapse** shrinks it to a tab |
| +against the nearest screen edge showing your microphone state and who the call |
| +is with (the other person's name, or a count); clicking the tab or dragging it |
| +away from the edge opens the mini window again, and double-clicking it brings |
| +the call window forward. Collapsed lasts for the rest of the call; every call |
| +starts expanded. The tab is the same window, so it floats, follows Spaces and |
| +stays out of screen captures exactly as the mini window does. There is no |
| +speaking light: nothing in the app measures audio level live. |
| + |
| +**Space** toggles the microphone while the call window (or the mini window) is |
| +the key window, unless a text field or a control has the keyboard, where Space |
| +is typing or the control's own press; a held Space toggles once. **Call → Mute |
| +Microphone** (⌘⇧M) is the same toggle. The browser room does the same (never in |
| +an input, textarea, select, contenteditable, button, link or other control, and |
| +announced through the polite live region), and on iPhone and iPad a hardware |
| +keyboard's Space does it through a key command that yields to text editing and |
| +focus. Push-to-talk (holding Space while muted) is not built. It shows what the call window's stage would show by |
| default: the newest shared screen, otherwise the first other participant with |
| their muted and camera-off state (there is no active-speaker tracking yet), or |
| your own camera while you are alone. It is not |
diff --git a/native/apple/Sources/CallsInterop/LobbySnapshots.swift b/native/apple/Sources/CallsInterop/LobbySnapshots.swift| index 446bd4a..c64f432 100644 |
| --- a/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| @@ -75,6 +75,7 @@ import SwiftUI |
| to: folder.appendingPathComponent("phone-settings-\(name(scheme)).png")) |
| } |
| try await SettingsSnapshots.save(to: folder) |
| + try await MiniWindowSnapshots.save(to: folder) |
| } |
| static func name(_ scheme: ColorScheme) -> String { scheme == .dark ? "dark" : "light" } |
diff --git a/native/apple/Sources/CallsInterop/MiniWindowSnapshots.swift b/native/apple/Sources/CallsInterop/MiniWindowSnapshots.swift| new file mode 100644 |
| index 0000000..2b47cab |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsInterop/MiniWindowSnapshots.swift |
| @@ -0,0 +1,32 @@ |
| +import AppKit |
| +import CallsKit |
| +import SwiftUI |
| + |
| +/// Offline visual fixtures of the mini window: at its smallest, default |
| +/// and a larger size, with and without the hover controls, and collapsed |
| +/// to a tab, muted and not. There is no call behind them, so the stage is |
| +/// your own (absent) camera and the tab says "Just you". |
| +@MainActor enum MiniWindowSnapshots { |
| + static func save(to folder: URL) async throws { |
| + for scheme in [ColorScheme.light, .dark] { |
| + let suffix = LobbySnapshots.name(scheme) |
| + for width in [MiniWindowRules.minimumWidth, MiniWindowRules.size.width, 480] { |
| + let size = CGSize(width: width, height: width / MiniWindowRules.aspect) |
| + for hovering in [false, true] { |
| + let call = Call(playAudio: false) |
| + try await LobbySnapshots.render( |
| + MiniWindowSnapshot.window(call: call, hovering: hovering), scheme: scheme, size: size, |
| + to: folder.appendingPathComponent( |
| + "mini-\(Int(width))\(hovering ? "-hover" : "")-\(suffix).png")) |
| + } |
| + } |
| + for muted in [false, true] { |
| + let call = Call(playAudio: false) |
| + if muted { call.mute() } |
| + try await LobbySnapshots.render( |
| + MiniWindowSnapshot.tab(call: call), scheme: scheme, size: MiniWindowTab.size, |
| + to: folder.appendingPathComponent("tab\(muted ? "-muted" : "")-\(suffix).png")) |
| + } |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/CallsSettingsView.swift b/native/apple/Sources/CallsKit/CallsSettingsView.swift| index ec58921..fa2791e 100644 |
| --- a/native/apple/Sources/CallsKit/CallsSettingsView.swift |
| +++ b/native/apple/Sources/CallsKit/CallsSettingsView.swift |
| @@ -1,7 +1,7 @@ |
| #if os(macOS) |
| import SwiftUI |
| - /// Calls → Settings…. Sections rather than tabs: there are two things |
| + /// Calls → Settings…. Sections rather than tabs: there are a few things |
| /// worth choosing, and the next preference belongs beside them. |
| public struct CallsSettingsView: View { |
| @ObservedObject private var model: CallsModel |
| @@ -9,6 +9,9 @@ |
| MiniWindowPreferences.defaultEnabled |
| @AppStorage(MiniWindowPreferences.cornerKey) private var corner = |
| MiniWindowPreferences.defaultCorner |
| + @AppStorage(MiniWindowPreferences.placementKey) private var placement = |
| + MiniWindowPreferences.defaultPlacement |
| + @AppStorage(MiniWindowPreferences.positionsKey) private var positions: String? |
| public init(model: CallsModel) { self.model = model } |
| @@ -21,15 +24,33 @@ |
| "During a call, a small window of the call stays on top of other apps. The full call returns when you come back to Calls." |
| ) |
| } |
| + Picker(selection: $placement) { |
| + ForEach(MiniWindowPlacement.allCases) { placement in |
| + Text(placement.title).tag(placement) |
| + } |
| + } label: { |
| + Text("Mini window placement") |
| + Text(placementNote) |
| + } |
| + .disabled(!miniWindow) |
| Picker(selection: $corner) { |
| ForEach(MiniWindowCorner.allCases) { corner in |
| Text(corner.title).tag(corner) |
| } |
| } label: { |
| Text("Mini window position") |
| - Text("You can also drag the mini window to another corner.") |
| + Text( |
| + placement == .corners |
| + ? "You can also drag the mini window to another corner, and resize it from its edges." |
| + : "Where it starts. Choosing a corner here moves it back into that corner." |
| + ) |
| } |
| .disabled(!miniWindow) |
| + .onChange(of: corner) { |
| + // A corner chosen here is asked for: whatever position the |
| + // window was left at, on any display, gives way to it. |
| + positions = nil |
| + } |
| } header: { |
| Text("During a call") |
| } |
| @@ -40,5 +61,14 @@ |
| .fixedSize(horizontal: false, vertical: true) |
| .tint(CallsAppearance.accent) |
| } |
| + |
| + private var placementNote: String { |
| + switch placement { |
| + case .corners: |
| + "A dragged mini window settles in the nearest corner. Hold ⌥ Option while dragging to leave it exactly where you drop it, for this call." |
| + case .anywhere: |
| + "The mini window stays exactly where you leave it, remembered for each display." |
| + } |
| + } |
| } |
| #endif |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index 7725a84..cff43bf 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -116,6 +116,9 @@ |
| if showingChat { readChatCount = call.receivedChatCount } |
| } |
| .task { await call.watchAudio() } |
| + // Space mutes and unmutes, unless a text field or a control has the |
| + // keyboard. The Call menu's ⌘⇧M is the same toggle, for when it has. |
| + .spaceToMute(call) |
| .onChange(of: call.receivedChatCount) { _, count in |
| if showingChat || chatWindow.isOpen { readChatCount = count } |
| } |
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift| index d5c8fcf..2da30ef 100644 |
| --- a/native/apple/Sources/CallsKit/MiniWindow.swift |
| +++ b/native/apple/Sources/CallsKit/MiniWindow.swift |
| @@ -22,11 +22,25 @@ |
| } |
| } |
| + 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. |
| + /// 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] = [] |
| @@ -41,6 +55,8 @@ |
| (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 |
| @@ -59,8 +75,11 @@ |
| guard self.model !== model else { return } |
| self.model = model |
| joining = model.$joining.removeDuplicates().sink { [weak self] joining in |
| - guard !joining, let self else { return } |
| - self.apply(self.policy.callEnded()) |
| + 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()) } |
| } |
| } |
| @@ -86,13 +105,18 @@ |
| case .none: break |
| case .show(let hideMain): |
| guard let model else { return } |
| - let screen = mainWindow?.screen ?? NSScreen.main |
| let panel = MiniWindowPanel( |
| - call: model.call, corner: preferences.corner, screen: screen, |
| - leave: { [weak model] in model?.leave() }, |
| - open: { NSApp.activate(ignoringOtherApps: true) }, |
| - moved: { [weak self] in self?.snap() }) |
| + call: model.call, |
| + actions: MiniWindowActions( |
| + leave: { [weak model] in model?.leave() }, |
| + open: { NSApp.activate(ignoringOtherApps: true) }, |
| + 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.orderFrontRegardless() |
| if hideMain, let mainWindow { Self.conceal(mainWindow, true) } |
| case .restore(let activate): |
| @@ -119,28 +143,116 @@ |
| window.ignoresMouseEvents = hidden |
| } |
| - /// A drag ends in the nearest corner, and that corner becomes the |
| - /// setting — the same one the Settings window shows — so the window |
| - /// is where it was left next time, until a corner is picked there. |
| - private func snap() { |
| - guard let panel, let screen = panel.screen ?? NSScreen.main else { return } |
| - let corner = MiniWindowCorner.nearest(to: panel.frame, in: screen.visibleFrame) |
| - preferences.corner = corner |
| - panel.place(corner, on: screen, animated: true) |
| + /// 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) |
| + } |
| } |
| } |
| - /// The floating window. 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. |
| + /// 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 { |
| - private let content: MiniWindowContent |
| + 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, corner: MiniWindowCorner, screen: NSScreen?, |
| - leave: @escaping () -> Void, open: @escaping () -> Void, moved: @escaping () -> Void |
| - ) { |
| + 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) |
| @@ -148,6 +260,9 @@ |
| 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. |
| @@ -160,14 +275,32 @@ |
| hasShadow = true |
| title = "Calls mini window" |
| let hosting = MiniWindowHostingView( |
| - rootView: MiniCallView(content: content, leave: leave, open: open, moved: moved)) |
| + 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 |
| - if let screen { place(corner, on: screen, animated: false) } |
| } |
| - func place(_ corner: MiniWindowCorner, on screen: NSScreen, animated: Bool) { |
| - let frame = corner.frame(size: MiniWindowRules.size, in: screen.visibleFrame) |
| + /// 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 } |
| + |
| + 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 |
| @@ -195,6 +328,7 @@ |
| @MainActor final class MiniWindowContent: ObservableObject { |
| @Published var call: Call? |
| @Published var hovering = false |
| + @Published var collapsed = false |
| init(call: Call) { self.call = call } |
| } |
| @@ -230,22 +364,30 @@ |
| struct MiniCallView: View { |
| @ObservedObject var content: MiniWindowContent |
| - let leave: () -> Void |
| - let open: () -> Void |
| - let moved: () -> Void |
| + let actions: MiniWindowActions |
| @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| var body: some View { |
| - ZStack { |
| - if let call = content.call { |
| - MiniCallStage(call: call, showingControls: showingControls, leave: leave) |
| + Group { |
| + if content.collapsed, let call = content.call { |
| + MiniTabView(call: call, actions: actions) |
| } else { |
| - Color.black |
| + ZStack { |
| + if let call = content.call { |
| + MiniCallStage(call: call, showingControls: showingControls, actions: actions) |
| + } 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)) |
| } |
| } |
| - .frame(width: MiniWindowRules.size.width, height: MiniWindowRules.size.height) |
| - .background(MiniWindowSurface(open: open, moved: moved)) |
| - .clipShape(RoundedRectangle(cornerRadius: 16)) |
| .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: showingControls) |
| .tint(CallsAppearance.accent) |
| } |
| @@ -260,7 +402,7 @@ |
| private struct MiniCallStage: View { |
| @ObservedObject var call: Call |
| let showingControls: Bool |
| - let leave: () -> Void |
| + let actions: MiniWindowActions |
| /// What the call window's stage shows when nobody has chosen: the |
| /// newest shared screen, since a person who shares is asking to be |
| @@ -296,7 +438,9 @@ |
| .allowsHitTesting(false) |
| VStack { |
| HStack(spacing: 8) { |
| - if call.muted { |
| + // 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).background(.black.opacity(0.65), in: Circle()) |
| @@ -304,6 +448,11 @@ |
| } |
| Spacer() |
| if showingControls { |
| + Button("Collapse to a tab", systemImage: "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", |
| systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| @@ -317,7 +466,7 @@ |
| .help(call.muted ? "Unmute microphone" : "Mute microphone") |
| Button( |
| "Leave meeting", systemImage: "phone.down.fill", role: .destructive, |
| - action: leave |
| + action: actions.leave |
| ) |
| .buttonStyle(CallActionStyle(color: .red, emphasized: true)) |
| .help("Leave meeting") |
| @@ -329,36 +478,155 @@ |
| } |
| } |
| - /// The window's background: a click brings Calls forward, a drag moves |
| - /// the window and then snaps it to a corner. Handled here rather than |
| - /// with isMovableByWindowBackground so the two can be told apart. |
| + /// 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. |
| + /// |
| + /// There is no speaking indicator: nothing in the app measures audio |
| + /// level live (the audio readout polls every five seconds), and a light |
| + /// that lags speech by seconds says the wrong thing. |
| + 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()) |
| + Text(caption).font(.callout.weight(.medium)).lineLimit(1).truncationMode(.tail) |
| + .foregroundStyle(.white) |
| + Spacer(minLength: 0) |
| + } |
| + .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: {}) |
| + ) |
| + .background(Color(white: 0.12).opacity(0.94)) |
| + .clipShape(Capsule()) |
| + .overlay(Capsule().strokeBorder(.white.opacity(0.15))) |
| + .accessibilityElement(children: .ignore) |
| + .accessibilityAddTraits(.isButton) |
| + .accessibilityLabel("Calls, \(caption)") |
| + .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 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 { |
| - let open: () -> Void |
| - let moved: () -> Void |
| + 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.open = open |
| - view.moved = moved |
| + view.mode = mode |
| + view.click = click |
| + view.dropped = dropped |
| + view.resized = resized |
| } |
| final class SurfaceView: NSView { |
| - var open: () -> Void = {} |
| - var moved: () -> Void = {} |
| + 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 } |
| - override func isAccessibilityElement() -> 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 { |
| - open() |
| + 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 start = NSEvent.mouseLocation |
| - let origin = window.frame.origin |
| + let startFrame = window.frame |
| + let grabbed = handle(at: convert(event.locationInWindow, from: nil)) |
| + let visible = window.screen?.visibleFrame ?? startFrame |
| var dragging = false |
| while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { |
| let now = NSEvent.mouseLocation |
| @@ -367,14 +635,45 @@ |
| // A few points of slack, so a click with a shaky hand is still |
| // a click and not a very short drag. |
| if !dragging, hypot(dx, dy) > 4 { dragging = true } |
| - if dragging { window.setFrameOrigin(NSPoint(x: origin.x + dx, y: origin.y + dy)) } |
| + 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 } |
| } |
| - if dragging { moved() } else { open() } |
| + if !dragging { |
| + click(event.clickCount) |
| + } else if grabbed.isEmpty { |
| + dropped(NSEvent.modifierFlags.contains(.option)) |
| + } else { |
| + resized() |
| + } |
| } |
| } |
| } |
| + /// 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 |
diff --git a/native/apple/Sources/CallsKit/MiniWindowRules.swift b/native/apple/Sources/CallsKit/MiniWindowRules.swift| index 35e7b1d..20a9c8f 100644 |
| --- a/native/apple/Sources/CallsKit/MiniWindowRules.swift |
| +++ b/native/apple/Sources/CallsKit/MiniWindowRules.swift |
| @@ -49,11 +49,231 @@ |
| } |
| } |
| + /// Whether a dragged mini window settles in a corner or stays where it |
| + /// was dropped. Corners by default, for the reason MiniWindowCorner |
| + /// gives; "anywhere" is for the person who knows which part of their |
| + /// screen they never look at. |
| + public enum MiniWindowPlacement: String, CaseIterable, Identifiable, Sendable { |
| + case corners, anywhere |
| + |
| + public var id: String { rawValue } |
| + |
| + public var title: String { |
| + switch self { |
| + case .corners: "Snap to corners" |
| + case .anywhere: "Anywhere I put it" |
| + } |
| + } |
| + } |
| + |
| public enum MiniWindowRules { |
| /// 16:9 at the width of a small picture in picture: big enough to |
| /// read a face, small enough to leave the app you went to usable. |
| + /// The size it opens at until someone resizes it. |
| public static let size = CGSize(width: 320, height: 180) |
| public static let inset: CGFloat = 20 |
| + public static let aspect: CGFloat = 16 / 9 |
| + /// Narrower than this and Mute, Leave and Collapse no longer fit |
| + /// across the top. |
| + public static let minimumWidth: CGFloat = 200 |
| + /// Wider than this share of the screen and it is no longer a mini |
| + /// window, it is a second call window covering the app you went to. |
| + public static let maximumShare: CGFloat = 0.4 |
| + |
| + /// The size for a width someone chose, held to 16:9 and to the limits |
| + /// of the screen it is on now — which may be a smaller one than the |
| + /// width was chosen on. |
| + public static func size(width: CGFloat, in visible: CGRect) -> CGSize { |
| + let widest = maximumSize(in: visible).width |
| + let chosen = min(max(width.rounded(), minimumWidth), widest) |
| + return CGSize(width: chosen, height: chosen / aspect) |
| + } |
| + |
| + public static func minimumSize() -> CGSize { |
| + CGSize(width: minimumWidth, height: minimumWidth / aspect) |
| + } |
| + |
| + /// Never below the minimum, even on a screen too small for 40% of it |
| + /// to be that wide. |
| + public static func maximumSize(in visible: CGRect) -> CGSize { |
| + let widest = max(minimumWidth, (visible.width * maximumShare).rounded(.down)) |
| + return CGSize(width: widest, height: widest / aspect) |
| + } |
| + |
| + /// `frame` moved the least distance that puts all of it inside |
| + /// `visible`. Its size is kept; one bigger than the screen is pinned |
| + /// to the top left, where the controls are. |
| + public static func clamp(_ frame: CGRect, into visible: CGRect) -> CGRect { |
| + var out = frame |
| + out.origin.x = min(max(frame.minX, visible.minX), visible.maxX - frame.width) |
| + out.origin.x = max(out.origin.x, visible.minX) |
| + out.origin.y = max(min(frame.minY, visible.maxY - frame.height), visible.minY) |
| + out.origin.y = min(out.origin.y, visible.maxY - frame.height) |
| + return out |
| + } |
| + |
| + /// Where a freely placed window is, relative to its screen's visible |
| + /// frame. Kept this way rather than in global coordinates so that |
| + /// moving a display in System Settings → Displays → Arrange does not |
| + /// throw the window off it. |
| + public static func offset(of frame: CGRect, in visible: CGRect) -> CGPoint { |
| + CGPoint(x: frame.minX - visible.minX, y: frame.minY - visible.minY) |
| + } |
| + |
| + /// A remembered offset put back on a screen, clamped: the display may |
| + /// have changed resolution, or been swapped for a smaller one. |
| + public static func frame(offset: CGPoint, size: CGSize, in visible: CGRect) -> CGRect { |
| + clamp( |
| + CGRect( |
| + origin: CGPoint(x: visible.minX + offset.x, y: visible.minY + offset.y), size: size), |
| + into: visible) |
| + } |
| + } |
| + |
| + /// Resizing the mini window from its edges and corners, always at |
| + /// 16:9. Done by the window's own surface rather than by AppKit's |
| + /// `.resizable`, whose edge handling on a borderless panel is not |
| + /// something to rely on, and which could not be held to this aspect |
| + /// and these limits in one place that tests can reach. |
| + public enum MiniWindowResize { |
| + public struct Handle: OptionSet, Hashable, Sendable { |
| + public let rawValue: Int |
| + public init(rawValue: Int) { self.rawValue = rawValue } |
| + public static let left = Handle(rawValue: 1) |
| + public static let right = Handle(rawValue: 2) |
| + public static let top = Handle(rawValue: 4) |
| + public static let bottom = Handle(rawValue: 8) |
| + } |
| + |
| + /// How close to an edge, inside the window, a press starts a resize. |
| + public static let grip: CGFloat = 8 |
| + |
| + /// The edges a press at `point` (in the window's own coordinates, |
| + /// origin bottom-left) takes hold of. Two at a corner, none in the |
| + /// middle, where a press is a click or a move. |
| + public static func handle(at point: CGPoint, in size: CGSize) -> Handle { |
| + var handle: Handle = [] |
| + if point.x <= grip { handle.insert(.left) } |
| + if point.x >= size.width - grip { handle.insert(.right) } |
| + if point.y <= grip { handle.insert(.bottom) } |
| + if point.y >= size.height - grip { handle.insert(.top) } |
| + return handle |
| + } |
| + |
| + /// The frame after dragging `handle` by (dx, dy) from `start`. The |
| + /// opposite edge or corner stays put; an edge on its own grows the |
| + /// window evenly either side of its middle. At a corner, whichever |
| + /// direction asks for the bigger change wins, so the corner follows |
| + /// the pointer as closely as 16:9 allows. |
| + public static func frame( |
| + from start: CGRect, dragging handle: Handle, dx: CGFloat, dy: CGFloat, in visible: CGRect |
| + ) -> CGRect { |
| + let horizontal: CGFloat? = |
| + handle.contains(.right) ? start.width + dx : handle.contains(.left) ? start.width - dx : nil |
| + let vertical: CGFloat? = |
| + handle.contains(.top) |
| + ? (start.height + dy) * MiniWindowRules.aspect |
| + : handle.contains(.bottom) ? (start.height - dy) * MiniWindowRules.aspect : nil |
| + let proposed: CGFloat |
| + switch (horizontal, vertical) { |
| + case (let h?, let v?): proposed = abs(h - start.width) >= abs(v - start.width) ? h : v |
| + case (let h?, nil): proposed = h |
| + case (nil, let v?): proposed = v |
| + case (nil, nil): return start |
| + } |
| + let size = MiniWindowRules.size(width: proposed, in: visible) |
| + let x: CGFloat = |
| + handle.contains(.left) |
| + ? start.maxX - size.width |
| + : handle.contains(.right) ? start.minX : start.midX - size.width / 2 |
| + let y: CGFloat = |
| + handle.contains(.bottom) |
| + ? start.maxY - size.height |
| + : handle.contains(.top) ? start.minY : start.midY - size.height / 2 |
| + return CGRect(origin: CGPoint(x: x, y: y), size: size) |
| + } |
| + } |
| + |
| + /// The screen edge a collapsed mini window sits against. |
| + public enum MiniWindowEdge: String, Sendable { |
| + case left, right, top, bottom |
| + } |
| + |
| + /// The mini window collapsed to a tab: small, flush against the nearest |
| + /// edge of the screen, out of the way of whatever the person went to |
| + /// the other app for. |
| + public enum MiniWindowTab { |
| + public static let size = CGSize(width: 156, height: 40) |
| + /// How far from its edge a dragged tab has to be let go to count as |
| + /// pulled out, rather than slid along the edge to somewhere else. |
| + public static let pullOut: CGFloat = 48 |
| + |
| + /// The edge `frame` is closest to. Ties go left and right before top |
| + /// and bottom, because the corners are where a snapped window lives |
| + /// and a tab beside it reads as belonging to the side it came from. |
| + public static func nearestEdge(to frame: CGRect, in visible: CGRect) -> MiniWindowEdge { |
| + let distances: [(MiniWindowEdge, CGFloat)] = [ |
| + (.right, visible.maxX - frame.maxX), (.left, frame.minX - visible.minX), |
| + (.bottom, frame.minY - visible.minY), (.top, visible.maxY - frame.maxY), |
| + ] |
| + return distances.min { $0.1 < $1.1 }!.0 |
| + } |
| + |
| + /// The tab for a window at `frame`: against its nearest edge, level |
| + /// with the window's middle along that edge, and on screen. |
| + public static func dock(_ frame: CGRect, in visible: CGRect) -> (MiniWindowEdge, CGRect) { |
| + let edge = nearestEdge(to: frame, in: visible) |
| + let x: CGFloat |
| + let y: CGFloat |
| + switch edge { |
| + case .left: (x, y) = (visible.minX, frame.midY - size.height / 2) |
| + case .right: (x, y) = (visible.maxX - size.width, frame.midY - size.height / 2) |
| + case .bottom: (x, y) = (frame.midX - size.width / 2, visible.minY) |
| + case .top: (x, y) = (frame.midX - size.width / 2, visible.maxY - size.height) |
| + } |
| + return ( |
| + edge, |
| + MiniWindowRules.clamp(CGRect(origin: CGPoint(x: x, y: y), size: size), into: visible) |
| + ) |
| + } |
| + |
| + public enum Release: Equatable { |
| + /// Back against an edge — the same one, or another it was slid to. |
| + case dock(MiniWindowEdge, CGRect) |
| + /// Pulled out: open the mini window again. |
| + case expand |
| + } |
| + |
| + /// What letting go of a dragged tab means. |
| + public static func release(_ frame: CGRect, in visible: CGRect) -> Release { |
| + let away = min( |
| + frame.minX - visible.minX, visible.maxX - frame.maxX, |
| + frame.minY - visible.minY, visible.maxY - frame.maxY) |
| + if away > pullOut { return .expand } |
| + let (edge, docked) = dock(frame, in: visible) |
| + return .dock(edge, docked) |
| + } |
| + |
| + /// A window of `size` opened from a tab: centred where the tab is, |
| + /// and on screen. |
| + public static func expanded(from tab: CGRect, size: CGSize, in visible: CGRect) -> CGRect { |
| + MiniWindowRules.clamp( |
| + CGRect( |
| + x: tab.midX - size.width / 2, y: tab.midY - size.height / 2, |
| + width: size.width, height: size.height), |
| + into: visible) |
| + } |
| + |
| + /// Who the call is with, in the few words a tab has room for: the |
| + /// other person's name when there is one, a count when there are |
| + /// more, and "Just you" when nobody else has come. |
| + public static func caption(others: [String]) -> String { |
| + switch others.count { |
| + case 0: "Just you" |
| + case 1: others[0] |
| + default: "\(others.count + 1) people" |
| + } |
| + } |
| } |
| /// The two preferences, read and written in one place so the Settings |
| @@ -65,8 +285,12 @@ |
| public struct MiniWindowPreferences { |
| public static let enabledKey = "miniWindow.enabled" |
| public static let cornerKey = "miniWindow.corner" |
| + public static let placementKey = "miniWindow.placement" |
| + public static let widthKey = "miniWindow.width" |
| + public static let positionsKey = "miniWindow.positions" |
| public static let defaultEnabled = true |
| public static let defaultCorner = MiniWindowCorner.bottomRight |
| + public static let defaultPlacement = MiniWindowPlacement.corners |
| private let defaults: UserDefaults |
| public init(defaults: UserDefaults = .standard) { self.defaults = defaults } |
| @@ -83,6 +307,116 @@ |
| } |
| nonmutating set { defaults.set(newValue.rawValue, forKey: Self.cornerKey) } |
| } |
| + |
| + public var placement: MiniWindowPlacement { |
| + get { |
| + defaults.string(forKey: Self.placementKey).flatMap(MiniWindowPlacement.init(rawValue:)) |
| + ?? Self.defaultPlacement |
| + } |
| + nonmutating set { defaults.set(newValue.rawValue, forKey: Self.placementKey) } |
| + } |
| + |
| + /// The width last resized to. Only the width: the height follows |
| + /// from 16:9, so there is nothing to disagree about. |
| + public var width: CGFloat { |
| + get { |
| + (defaults.object(forKey: Self.widthKey) as? Double).map { CGFloat($0) } |
| + ?? MiniWindowRules.size.width |
| + } |
| + nonmutating set { defaults.set(Double(newValue), forKey: Self.widthKey) } |
| + } |
| + |
| + /// Where the window was left in "anywhere" mode, per display, as an |
| + /// offset from that display's visible frame (see |
| + /// MiniWindowRules.offset). Keyed by the display's ID as a string, and |
| + /// kept as one JSON string so that Settings can clear it through |
| + /// AppStorage. |
| + public func position(on display: String) -> CGPoint? { |
| + guard let pair = positions[display], pair.count == 2 else { return nil } |
| + return CGPoint(x: pair[0], y: pair[1]) |
| + } |
| + |
| + public func setPosition(_ offset: CGPoint, on display: String) { |
| + var all = positions |
| + all[display] = [Double(offset.x), Double(offset.y)] |
| + guard let data = try? JSONEncoder().encode(all) else { return } |
| + defaults.set(String(decoding: data, as: UTF8.self), forKey: Self.positionsKey) |
| + } |
| + |
| + private var positions: [String: [Double]] { |
| + guard let text = defaults.string(forKey: Self.positionsKey), |
| + let all = try? JSONDecoder().decode([String: [Double]].self, from: Data(text.utf8)) |
| + else { return [:] } |
| + return all |
| + } |
| + |
| + public func forgetPositions() { defaults.removeObject(forKey: Self.positionsKey) } |
| + } |
| + |
| + /// What the mini window remembers for one call and forgets when it |
| + /// ends: whether it was collapsed to a tab, and any position it was |
| + /// ⌥-dragged to while the setting is "Snap to corners". Every call |
| + /// starts expanded — a tab left over from yesterday's call would be a |
| + /// call nobody can see they are in. |
| + public struct MiniWindowCallMemory: Equatable { |
| + public var collapsed = false |
| + /// ⌥-dropped positions by display, as offsets. One-offs: they win |
| + /// over the corner for the rest of this call and are never saved. |
| + public private(set) var oneOff: [String: CGPoint] = [:] |
| + |
| + public init() {} |
| + |
| + /// Where a newly shown window of `size` goes on `display`. |
| + public func frame( |
| + size: CGSize, in visible: CGRect, display: String, preferences: MiniWindowPreferences |
| + ) -> CGRect { |
| + if let offset = oneOff[display] { |
| + return MiniWindowRules.frame(offset: offset, size: size, in: visible) |
| + } |
| + if preferences.placement == .anywhere, let offset = preferences.position(on: display) { |
| + return MiniWindowRules.frame(offset: offset, size: size, in: visible) |
| + } |
| + return preferences.corner.frame(size: size, in: visible) |
| + } |
| + |
| + /// A drag ended with the window at `frame` on `display`. Returns |
| + /// where it settles. |
| + /// |
| + /// - "Anywhere I put it": exactly there (clamped on screen), saved for |
| + /// that display. |
| + /// - "Snap to corners" with ⌥ held: exactly there, for this call only. |
| + /// - "Snap to corners": the nearest corner, which becomes the setting, |
| + /// and any one-off on that display is dropped. |
| + public mutating func dropped( |
| + _ frame: CGRect, in visible: CGRect, display: String, option: Bool, |
| + preferences: MiniWindowPreferences |
| + ) -> CGRect { |
| + let placed = MiniWindowRules.clamp(frame, into: visible) |
| + switch (preferences.placement, option) { |
| + case (.anywhere, _): |
| + preferences.setPosition(MiniWindowRules.offset(of: placed, in: visible), on: display) |
| + return placed |
| + case (.corners, true): |
| + oneOff[display] = MiniWindowRules.offset(of: placed, in: visible) |
| + return placed |
| + case (.corners, false): |
| + oneOff[display] = nil |
| + let corner = MiniWindowCorner.nearest(to: frame, in: visible) |
| + preferences.corner = corner |
| + return corner.frame(size: frame.size, in: visible) |
| + } |
| + } |
| + |
| + /// A resize ended with the window at `frame`. It settles as a drop |
| + /// would, except that a window ⌥-placed for this call stays placed |
| + /// rather than snapping: resizing it is not asking for a corner. |
| + public mutating func resized( |
| + _ frame: CGRect, in visible: CGRect, display: String, preferences: MiniWindowPreferences |
| + ) -> CGRect { |
| + dropped( |
| + frame, in: visible, display: display, option: oneOff[display] != nil, |
| + preferences: preferences) |
| + } |
| } |
| /// When the mini window comes and goes. Kept apart from the windows it |
diff --git a/native/apple/Sources/CallsKit/SpaceToMute.swift b/native/apple/Sources/CallsKit/SpaceToMute.swift| new file mode 100644 |
| index 0000000..de2dcd9 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/SpaceToMute.swift |
| @@ -0,0 +1,132 @@ |
| +import Foundation |
| + |
| +/// What has the keyboard in a window when Space is pressed. |
| +public enum KeyboardFocus: Equatable, Sendable { |
| + /// Nothing in particular: the window itself, or its root view. |
| + case none |
| + /// A text field or text view, where Space is a space. |
| + case text |
| + /// A button, picker or any other control, where Space is the |
| + /// control's own activation. Anything not known to be one of the |
| + /// other two counts as this: an unrecognised focused thing keeps its |
| + /// Space rather than losing it to the microphone. |
| + case control |
| +} |
| + |
| +/// Space toggles the microphone during a call, the way it does in most |
| +/// calling apps — but only when Space means nothing else. The rules are |
| +/// here, apart from the key monitor that applies them, so they can be |
| +/// held by tests without a window or a keyboard. |
| +public enum SpaceToMute { |
| + public enum Action: Equatable, Sendable { |
| + /// Toggle mute, and consume the key. |
| + case toggle |
| + /// Consume the key and do nothing: a held Space's key repeats, which |
| + /// would otherwise flicker the microphone or beep. |
| + case swallow |
| + /// Not ours: let the key go on to whatever has focus. |
| + case pass |
| + } |
| + |
| + /// The virtual key code of the Space bar on every Mac keyboard layout. |
| + public static let keyCode: UInt16 = 49 |
| + |
| + /// - Parameters: |
| + /// - isSpace: the key is the Space bar. |
| + /// - isRepeat: the event is the key repeating while held. |
| + /// - modified: ⌘, ⌃ or ⌥ (or ⇧) is held. ⇧Space and the rest are |
| + /// shortcuts of their own, never the bare Space this is. |
| + /// - focus: what has the keyboard. |
| + public static func action( |
| + isSpace: Bool, isRepeat: Bool, modified: Bool, focus: KeyboardFocus |
| + ) -> Action { |
| + guard isSpace, !modified, focus == .none else { return .pass } |
| + return isRepeat ? .swallow : .toggle |
| + } |
| +} |
| + |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + |
| + extension SpaceToMute { |
| + /// What has the keyboard, from AppKit's first responder. A SwiftUI |
| + /// TextField edits through the window's field editor, an NSText; a |
| + /// SwiftUI Button focused by Full Keyboard Access is a private proxy |
| + /// view inside the hosting view, which is why anything unrecognised |
| + /// is a control. The window, nothing, or the root view is nobody. |
| + public static func focus(of responder: NSResponder?, contentView: NSView?) -> KeyboardFocus { |
| + switch responder { |
| + case nil: return .none |
| + case is NSWindow: return .none |
| + case let view as NSView where view === contentView: return .none |
| + case is NSText: return .text |
| + case let field as NSTextField where field.isEditable: return .text |
| + default: return .control |
| + } |
| + } |
| + |
| + /// The decision for a key event arriving in `window`. |
| + @MainActor static func action(for event: NSEvent, in window: NSWindow) -> Action { |
| + guard event.type == .keyDown else { return .pass } |
| + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) |
| + .subtracting([.capsLock, .numericPad, .function]) |
| + return action( |
| + isSpace: event.keyCode == keyCode, isRepeat: event.isARepeat, modified: !modifiers.isEmpty, |
| + focus: focus(of: window.firstResponder, contentView: window.contentView)) |
| + } |
| + } |
| + |
| + extension View { |
| + /// Space toggles `call`'s microphone while this view's window — or the |
| + /// mini window standing in for it — is the key window. Apply it to the |
| + /// call's own view. |
| + func spaceToMute(_ call: Call) -> some View { |
| + background(SpaceToMuteMonitor(call: call).frame(width: 0, height: 0).accessibilityHidden(true)) |
| + } |
| + } |
| + |
| + /// Holds a local key monitor for as long as the call's view is in a |
| + /// window. Local monitors see every key event the app receives, so the |
| + /// monitor checks which window the event is for before anything else. |
| + private struct SpaceToMuteMonitor: NSViewRepresentable { |
| + let call: Call |
| + |
| + func makeNSView(context: Context) -> MonitorView { MonitorView() } |
| + func updateNSView(_ view: MonitorView, context: Context) { view.call = call } |
| + |
| + final class MonitorView: NSView { |
| + weak var call: Call? |
| + private var monitor: Any? |
| + |
| + override func viewDidMoveToWindow() { |
| + super.viewDidMoveToWindow() |
| + if let monitor { NSEvent.removeMonitor(monitor) } |
| + monitor = nil |
| + guard window != nil else { return } |
| + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in |
| + // Local monitors run on the main thread, with the event the app |
| + // is about to dispatch there. |
| + nonisolated(unsafe) let event = event |
| + let consumed = MainActor.assumeIsolated { () -> Bool in |
| + guard let self, let host = self.window, let call = self.call, |
| + let target = event.window, target === host || target is MiniWindowPanel |
| + else { return false } |
| + switch SpaceToMute.action(for: event, in: target) { |
| + case .pass: return false |
| + case .swallow: return true |
| + case .toggle: |
| + call.mute() |
| + return true |
| + } |
| + } |
| + return consumed ? nil : event |
| + } |
| + } |
| + |
| + deinit { |
| + if let monitor { NSEvent.removeMonitor(monitor) } |
| + } |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index 22234d2..ca5c22d 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -27,6 +27,7 @@ import LiveKitWebRTC |
| .commands { |
| CommandGroup(replacing: .newItem) {} |
| NativeUpdateCommands(updater: updater) |
| + CommandMenu("Call") { CallMenuItems(model: model) } |
| } |
| Settings { CallsSettingsView(model: model) } |
| } |
| @@ -86,6 +87,23 @@ final class CallsAppDelegate: NSObject, NSApplicationDelegate { |
| } |
| } |
| +/// The Call menu. Mute is here so that its shortcut can be found, and so |
| +/// that there is one when Space belongs to a text field or a button. |
| +struct CallMenuItems: View { |
| + @ObservedObject var model: CallsModel |
| + var body: some View { MuteMenuItem(call: model.call, inCall: model.joining) } |
| +} |
| + |
| +private struct MuteMenuItem: View { |
| + @ObservedObject var call: Call |
| + let inCall: Bool |
| + var body: some View { |
| + Button(call.muted ? "Unmute Microphone" : "Mute Microphone") { call.mute() } |
| + .keyboardShortcut("m", modifiers: [.command, .shift]) |
| + .disabled(!inCall) |
| + } |
| +} |
| + |
| struct ContentView: View { |
| @ObservedObject var model: CallsModel |
| var body: some View { |
diff --git a/native/apple/Tests/CallsKitTests/MiniWindowTests.swift b/native/apple/Tests/CallsKitTests/MiniWindowTests.swift| index 64328d4..4b11517 100644 |
| --- a/native/apple/Tests/CallsKitTests/MiniWindowTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/MiniWindowTests.swift |
| @@ -172,25 +172,400 @@ |
| } |
| } |
| + /// Size: 16:9 always, never under 200 pt wide, never over 40% of the |
| + /// screen, and remembered. |
| + final class MiniWindowSizeTests: XCTestCase { |
| + private let visible = CGRect(x: 0, y: 70, width: 1440, height: 805) |
| + |
| + func testEverySizeIs16By9() { |
| + for width in [0, 150, 200, 247, 320, 575, 900, 5000] as [CGFloat] { |
| + let size = MiniWindowRules.size(width: width, in: visible) |
| + XCTAssertEqual(size.width / size.height, 16 / 9, accuracy: 0.0001, "\(width)") |
| + } |
| + } |
| + |
| + func testTheWidthIsHeldBetweenTheMinimumAnd40PercentOfTheScreen() { |
| + XCTAssertEqual(MiniWindowRules.size(width: 120, in: visible).width, 200) |
| + XCTAssertEqual(MiniWindowRules.size(width: 320, in: visible).width, 320) |
| + XCTAssertEqual(MiniWindowRules.size(width: 900, in: visible).width, 576) // 1440 × 0.4 |
| + // A width chosen on a big display, shown on a small one. |
| + let small = CGRect(x: 0, y: 0, width: 1024, height: 700) |
| + XCTAssertEqual(MiniWindowRules.size(width: 576, in: small).width, 409) |
| + // A screen too small for 40% of it to reach the minimum still gets it. |
| + let tiny = CGRect(x: 0, y: 0, width: 400, height: 300) |
| + XCTAssertEqual(MiniWindowRules.size(width: 900, in: tiny).width, 200) |
| + } |
| + |
| + func testResizingFromACornerKeepsTheOppositeCornerAndTheAspect() { |
| + let start = CGRect(x: 1100, y: 90, width: 320, height: 180) |
| + // The top-left corner, dragged up and left: the bottom right stays. |
| + let grown = MiniWindowResize.frame( |
| + from: start, dragging: [.top, .left], dx: -80, dy: 20, in: visible) |
| + XCTAssertEqual(grown.maxX, start.maxX) |
| + XCTAssertEqual(grown.minY, start.minY) |
| + XCTAssertEqual(grown.width, 400) |
| + XCTAssertEqual(grown.width / grown.height, 16 / 9, accuracy: 0.0001) |
| + // Whichever direction asks for more wins. |
| + let tall = MiniWindowResize.frame( |
| + from: start, dragging: [.top, .left], dx: -10, dy: 90, in: visible) |
| + XCTAssertEqual(tall.height, 270, accuracy: 0.001) |
| + XCTAssertEqual(tall.width, 480) |
| + } |
| + |
| + func testResizingIsClampedAtBothEnds() { |
| + let start = CGRect(x: 100, y: 100, width: 320, height: 180) |
| + let shrunk = MiniWindowResize.frame( |
| + from: start, dragging: .right, dx: -500, dy: 0, in: visible) |
| + XCTAssertEqual(shrunk.size, MiniWindowRules.minimumSize()) |
| + let huge = MiniWindowResize.frame( |
| + from: start, dragging: .bottom, dx: 0, dy: -2000, in: visible) |
| + XCTAssertEqual(huge.size, MiniWindowRules.maximumSize(in: visible)) |
| + XCTAssertEqual(huge.maxY, start.maxY, "a bottom edge grows downward") |
| + // An edge alone grows evenly either side of the middle. |
| + let wide = MiniWindowResize.frame(from: start, dragging: .right, dx: 80, dy: 0, in: visible) |
| + XCTAssertEqual(wide.minX, start.minX) |
| + XCTAssertEqual(wide.midY, start.midY, accuracy: 0.001) |
| + } |
| + |
| + func testThePressTakesHoldOfTheEdgesItIsNear() { |
| + let size = CGSize(width: 320, height: 180) |
| + XCTAssertEqual(MiniWindowResize.handle(at: CGPoint(x: 160, y: 90), in: size), []) |
| + XCTAssertEqual(MiniWindowResize.handle(at: CGPoint(x: 2, y: 90), in: size), .left) |
| + XCTAssertEqual(MiniWindowResize.handle(at: CGPoint(x: 318, y: 178), in: size), [.right, .top]) |
| + XCTAssertEqual(MiniWindowResize.handle(at: CGPoint(x: 3, y: 3), in: size), [.left, .bottom]) |
| + } |
| + |
| + func testTheSizeIsRemembered() { |
| + let name = "MiniWindowSizeTests.\(UUID().uuidString)" |
| + let store = UserDefaults(suiteName: name)! |
| + defer { store.removePersistentDomain(forName: name) } |
| + XCTAssertEqual(MiniWindowPreferences(defaults: store).width, 320) |
| + MiniWindowPreferences(defaults: store).width = 480 |
| + XCTAssertEqual(MiniWindowPreferences(defaults: store).width, 480) |
| + } |
| + |
| + func testSnappingKeepsTheChosenSize() { |
| + let name = "MiniWindowSizeTests.\(UUID().uuidString)" |
| + let store = UserDefaults(suiteName: name)! |
| + defer { store.removePersistentDomain(forName: name) } |
| + var memory = MiniWindowCallMemory() |
| + let dropped = CGRect(x: 800, y: 500, width: 480, height: 270) |
| + let settled = memory.dropped( |
| + dropped, in: visible, display: "1", option: false, |
| + preferences: MiniWindowPreferences(defaults: store)) |
| + XCTAssertEqual(settled, MiniWindowCorner.topRight.frame(size: dropped.size, in: visible)) |
| + } |
| + } |
| + |
| + /// The tab: against the nearest edge, off it by dragging, and saying who |
| + /// the call is with. |
| + final class MiniWindowTabTests: XCTestCase { |
| + private let visible = CGRect(x: 0, y: 70, width: 1440, height: 805) |
| + private let size = MiniWindowTab.size |
| + |
| + func testEachCornerWindowDocksToItsSide() { |
| + let window = CGSize(width: 320, height: 180) |
| + let (right, tab) = MiniWindowTab.dock( |
| + MiniWindowCorner.bottomRight.frame(size: window, in: visible), in: visible) |
| + XCTAssertEqual(right, .right) |
| + XCTAssertEqual(tab.maxX, visible.maxX, "flush against the edge") |
| + XCTAssertEqual(tab.midY, 90 + 90, accuracy: 0.001, "level with the window's middle") |
| + XCTAssertEqual(tab.size, size) |
| + XCTAssertEqual( |
| + MiniWindowTab.dock(MiniWindowCorner.topLeft.frame(size: window, in: visible), in: visible).0, |
| + .left) |
| + } |
| + |
| + func testAWindowNearerTheTopOrBottomDocksThere() { |
| + let nearBottom = CGRect(x: 600, y: 75, width: 320, height: 180) |
| + let (edge, tab) = MiniWindowTab.dock(nearBottom, in: visible) |
| + XCTAssertEqual(edge, .bottom) |
| + XCTAssertEqual(tab.minY, visible.minY) |
| + XCTAssertEqual(tab.midX, nearBottom.midX, accuracy: 0.001) |
| + let nearTop = CGRect(x: 600, y: 690, width: 320, height: 180) |
| + XCTAssertEqual(MiniWindowTab.dock(nearTop, in: visible).0, .top) |
| + XCTAssertEqual(MiniWindowTab.dock(nearTop, in: visible).1.maxY, visible.maxY) |
| + } |
| + |
| + func testTheTabStaysOnScreen() { |
| + // A window half off the bottom right: the tab is still all on screen. |
| + let off = CGRect(x: 1400, y: 20, width: 320, height: 180) |
| + let tab = MiniWindowTab.dock(off, in: visible).1 |
| + XCTAssertTrue(visible.contains(tab), "\(tab)") |
| + } |
| + |
| + func testDraggingAlongAnEdgeRedocksAndPullingItOutExpands() { |
| + let docked = MiniWindowTab.dock(CGRect(x: 1100, y: 90, width: 320, height: 180), in: visible).1 |
| + // Slid up the right edge, a little off it. |
| + let slid = docked.offsetBy(dx: -20, dy: 300) |
| + guard case .dock(.right, let frame) = MiniWindowTab.release(slid, in: visible) else { |
| + return XCTFail("slid along the edge should dock again") |
| + } |
| + XCTAssertEqual(frame.maxX, visible.maxX) |
| + XCTAssertEqual(frame.minY, slid.minY) |
| + // Pulled into the middle of the screen. |
| + XCTAssertEqual(MiniWindowTab.release(docked.offsetBy(dx: -500, dy: 200), in: visible), .expand) |
| + } |
| + |
| + func testAPulledOutWindowOpensWhereTheTabWasAndOnScreen() { |
| + let tab = CGRect(x: 1284, y: 400, width: size.width, height: size.height) |
| + let frame = MiniWindowTab.expanded( |
| + from: tab, size: CGSize(width: 320, height: 180), in: visible) |
| + XCTAssertEqual(frame.midY, tab.midY, accuracy: 0.001) |
| + XCTAssertEqual(frame.maxX, visible.maxX, "clamped rather than hanging off the edge") |
| + } |
| + |
| + func testTheCaption() { |
| + XCTAssertEqual(MiniWindowTab.caption(others: []), "Just you") |
| + XCTAssertEqual(MiniWindowTab.caption(others: ["Bea"]), "Bea") |
| + XCTAssertEqual(MiniWindowTab.caption(others: ["Bea", "Cai"]), "3 people") |
| + } |
| + } |
| + |
| + /// Placement: corners, anywhere, and ⌥ for one call. |
| + final class MiniWindowPlacementTests: XCTestCase { |
| + private let visible = CGRect(x: 0, y: 70, width: 1440, height: 805) |
| + private let size = CGSize(width: 320, height: 180) |
| + |
| + private func preferences() -> MiniWindowPreferences { |
| + let name = "MiniWindowPlacementTests.\(UUID().uuidString)" |
| + let defaults = UserDefaults(suiteName: name)! |
| + addTeardownBlock { defaults.removePersistentDomain(forName: name) } |
| + return MiniWindowPreferences(defaults: defaults) |
| + } |
| + |
| + func testCornersByDefault() { |
| + let preferences = preferences() |
| + XCTAssertEqual(preferences.placement, .corners) |
| + XCTAssertEqual( |
| + MiniWindowPlacement.allCases.map(\.title), ["Snap to corners", "Anywhere I put it"]) |
| + XCTAssertEqual( |
| + MiniWindowCallMemory().frame(size: size, in: visible, display: "1", preferences: preferences), |
| + MiniWindowCorner.bottomRight.frame(size: size, in: visible)) |
| + } |
| + |
| + func testAnywhereKeepsTheDropExactlyAndRemembersItPerDisplay() { |
| + let preferences = preferences() |
| + preferences.placement = .anywhere |
| + var memory = MiniWindowCallMemory() |
| + let drop = CGRect(x: 612, y: 333, width: 320, height: 180) |
| + XCTAssertEqual( |
| + memory.dropped(drop, in: visible, display: "1", option: false, preferences: preferences), |
| + drop) |
| + XCTAssertEqual(preferences.corner, .bottomRight, "a free drop is not a corner") |
| + // Next call, same display: there. Another display: its own default. |
| + let next = MiniWindowCallMemory() |
| + XCTAssertEqual(next.frame(size: size, in: visible, display: "1", preferences: preferences), drop) |
| + let other = CGRect(x: 1440, y: 0, width: 1920, height: 1055) |
| + XCTAssertEqual( |
| + next.frame(size: size, in: other, display: "2", preferences: preferences), |
| + MiniWindowCorner.bottomRight.frame(size: size, in: other)) |
| + } |
| + |
| + func testAFreePositionFollowsItsDisplayWhenTheArrangementChanges() { |
| + let preferences = preferences() |
| + preferences.placement = .anywhere |
| + var memory = MiniWindowCallMemory() |
| + let right = CGRect(x: 1440, y: 0, width: 1920, height: 1055) |
| + _ = memory.dropped( |
| + CGRect(x: 2000, y: 400, width: 320, height: 180), in: right, display: "2", option: false, |
| + preferences: preferences) |
| + // The display dragged to the left of the main one in Arrange. |
| + let left = CGRect(x: -1920, y: 0, width: 1920, height: 1055) |
| + XCTAssertEqual( |
| + memory.frame(size: size, in: left, display: "2", preferences: preferences), |
| + CGRect(x: -1920 + 560, y: 400, width: 320, height: 180)) |
| + } |
| + |
| + func testAFreePositionIsClampedBackOnAScreenThatShrank() { |
| + let preferences = preferences() |
| + preferences.placement = .anywhere |
| + var memory = MiniWindowCallMemory() |
| + let big = CGRect(x: 0, y: 0, width: 2560, height: 1415) |
| + _ = memory.dropped( |
| + CGRect(x: 2200, y: 1200, width: 320, height: 180), in: big, display: "1", option: false, |
| + preferences: preferences) |
| + // Same display at a lower resolution. |
| + let smaller = CGRect(x: 0, y: 0, width: 1440, height: 875) |
| + let frame = memory.frame(size: size, in: smaller, display: "1", preferences: preferences) |
| + XCTAssertTrue(smaller.contains(frame), "\(frame)") |
| + XCTAssertEqual(frame.maxX, smaller.maxX) |
| + XCTAssertEqual(frame.maxY, smaller.maxY) |
| + // And a drop half off the screen lands on it. |
| + let half = memory.dropped( |
| + CGRect(x: -100, y: -50, width: 320, height: 180), in: smaller, display: "1", option: false, |
| + preferences: preferences) |
| + XCTAssertEqual(half.origin, CGPoint(x: 0, y: 0)) |
| + } |
| + |
| + func testOptionDragPlacesFreelyForThisCallOnly() { |
| + let preferences = preferences() |
| + var memory = MiniWindowCallMemory() |
| + let drop = CGRect(x: 612, y: 333, width: 320, height: 180) |
| + XCTAssertEqual( |
| + memory.dropped(drop, in: visible, display: "1", option: true, preferences: preferences), drop) |
| + XCTAssertEqual(preferences.corner, .bottomRight, "the setting is untouched") |
| + XCTAssertNil(preferences.position(on: "1"), "and nothing is saved") |
| + // Shown again in the same call: where it was dropped. |
| + XCTAssertEqual(memory.frame(size: size, in: visible, display: "1", preferences: preferences), drop) |
| + // Resizing it keeps it placed rather than snapping it. |
| + let bigger = CGRect(x: 612, y: 333, width: 400, height: 225) |
| + XCTAssertEqual( |
| + memory.resized(bigger, in: visible, display: "1", preferences: preferences), bigger) |
| + // The next call starts from the setting again. |
| + XCTAssertEqual( |
| + MiniWindowCallMemory().frame(size: size, in: visible, display: "1", preferences: preferences), |
| + MiniWindowCorner.bottomRight.frame(size: size, in: visible)) |
| + } |
| + |
| + func testAnOrdinaryDragAfterAnOptionDragSnapsAndForgetsTheOneOff() { |
| + let preferences = preferences() |
| + var memory = MiniWindowCallMemory() |
| + _ = memory.dropped( |
| + CGRect(x: 612, y: 333, width: 320, height: 180), in: visible, display: "1", option: true, |
| + preferences: preferences) |
| + let snapped = memory.dropped( |
| + CGRect(x: 100, y: 600, width: 320, height: 180), in: visible, display: "1", option: false, |
| + preferences: preferences) |
| + XCTAssertEqual(snapped, MiniWindowCorner.topLeft.frame(size: size, in: visible)) |
| + XCTAssertEqual(preferences.corner, .topLeft) |
| + XCTAssertEqual(memory.frame(size: size, in: visible, display: "1", preferences: preferences), snapped) |
| + XCTAssertTrue(memory.oneOff.isEmpty) |
| + } |
| + |
| + /// Collapsed, and any ⌥ one-off, last for the call and no longer. |
| + @MainActor func testEachCallStartsExpandedWithNoOneOffs() { |
| + let name = "MiniWindowPlacementTests.\(UUID().uuidString)" |
| + let defaults = UserDefaults(suiteName: name)! |
| + defer { defaults.removePersistentDomain(forName: name) } |
| + let model = CallsModel(defaults: defaults) |
| + let controller = MiniWindowController(preferences: MiniWindowPreferences(defaults: defaults)) |
| + controller.attach(model: model, mainWindow: nil) |
| + model.joining = true |
| + controller.memory.collapsed = true |
| + _ = controller.memory.dropped( |
| + CGRect(x: 612, y: 333, width: 320, height: 180), in: visible, display: "1", option: true, |
| + preferences: MiniWindowPreferences(defaults: defaults)) |
| + XCTAssertNotEqual(controller.memory, MiniWindowCallMemory()) |
| + model.joining = false |
| + XCTAssertEqual(controller.memory, MiniWindowCallMemory()) |
| + XCTAssertFalse(controller.memory.collapsed) |
| + } |
| + |
| + func testForgettingPositionsPutsItBackInTheCorner() { |
| + let preferences = preferences() |
| + preferences.placement = .anywhere |
| + preferences.setPosition(CGPoint(x: 10, y: 10), on: "1") |
| + preferences.forgetPositions() |
| + XCTAssertNil(preferences.position(on: "1")) |
| + } |
| + } |
| + |
| + /// Space-to-mute's rules, and how AppKit's first responder is read. |
| + final class SpaceToMuteTests: XCTestCase { |
| + func testBareSpaceWithNothingFocusedToggles() { |
| + XCTAssertEqual( |
| + SpaceToMute.action(isSpace: true, isRepeat: false, modified: false, focus: .none), .toggle) |
| + } |
| + |
| + func testARepeatIsSwallowedNotToggled() { |
| + XCTAssertEqual( |
| + SpaceToMute.action(isSpace: true, isRepeat: true, modified: false, focus: .none), .swallow) |
| + } |
| + |
| + func testTextFieldsAndControlsKeepTheirSpace() { |
| + for focus in [KeyboardFocus.text, .control] { |
| + for repeating in [false, true] { |
| + XCTAssertEqual( |
| + SpaceToMute.action(isSpace: true, isRepeat: repeating, modified: false, focus: focus), |
| + .pass) |
| + } |
| + } |
| + } |
| + |
| + func testOtherKeysAndModifiedSpaceAreNotOurs() { |
| + XCTAssertEqual( |
| + SpaceToMute.action(isSpace: false, isRepeat: false, modified: false, focus: .none), .pass) |
| + XCTAssertEqual( |
| + SpaceToMute.action(isSpace: true, isRepeat: false, modified: true, focus: .none), .pass) |
| + } |
| + |
| + @MainActor func testTheFirstResponderIsReadConservatively() { |
| + let root = NSView() |
| + XCTAssertEqual(SpaceToMute.focus(of: nil, contentView: root), .none) |
| + XCTAssertEqual(SpaceToMute.focus(of: NSWindow(), contentView: root), .none) |
| + XCTAssertEqual(SpaceToMute.focus(of: root, contentView: root), .none) |
| + // The field editor a SwiftUI TextField (the chat composer) edits |
| + // through, and an editable field itself. |
| + XCTAssertEqual(SpaceToMute.focus(of: NSTextView(), contentView: root), .text) |
| + XCTAssertEqual(SpaceToMute.focus(of: NSTextField(string: ""), contentView: root), .text) |
| + XCTAssertEqual(SpaceToMute.focus(of: NSButton(), contentView: root), .control) |
| + XCTAssertEqual(SpaceToMute.focus(of: NSPopUpButton(), contentView: root), .control) |
| + // Anything unrecognised — SwiftUI's focus proxy for a Button among |
| + // them — keeps its Space. |
| + XCTAssertEqual(SpaceToMute.focus(of: NSView(), contentView: root), .control) |
| + } |
| + |
| + @MainActor func testAKeyEventInAWindowIsDecidedByItsFirstResponder() throws { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + let window = NSWindow( |
| + contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), styleMask: [.titled], |
| + backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + let field = NSTextField(frame: NSRect(x: 10, y: 10, width: 100, height: 24)) |
| + window.contentView?.addSubview(field) |
| + func key(_ code: UInt16, _ characters: String, repeating: Bool = false, flags: NSEvent.ModifierFlags = []) |
| + throws -> NSEvent |
| + { |
| + try XCTUnwrap( |
| + NSEvent.keyEvent( |
| + with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, |
| + windowNumber: window.windowNumber, context: nil, characters: characters, |
| + charactersIgnoringModifiers: characters, isARepeat: repeating, keyCode: code)) |
| + } |
| + window.makeFirstResponder(nil) |
| + XCTAssertEqual(SpaceToMute.action(for: try key(49, " "), in: window), .toggle) |
| + XCTAssertEqual(SpaceToMute.action(for: try key(49, " ", repeating: true), in: window), .swallow) |
| + XCTAssertEqual(SpaceToMute.action(for: try key(49, " ", flags: .command), in: window), .pass) |
| + XCTAssertEqual(SpaceToMute.action(for: try key(49, " ", flags: .capsLock), in: window), .toggle) |
| + XCTAssertEqual(SpaceToMute.action(for: try key(46, "m"), in: window), .pass) |
| + window.makeFirstResponder(field) |
| + XCTAssertEqual(SpaceToMute.action(for: try key(49, " "), in: window), .pass, "typing a space") |
| + window.close() |
| + } |
| + } |
| + |
| final class MiniWindowPanelTests: XCTestCase { |
| @MainActor func testThePanelFloatsEverywhereAndStaysOutOfScreenCaptures() throws { |
| NSApplication.shared.setActivationPolicy(.prohibited) |
| let screen = try XCTUnwrap(NSScreen.main) |
| - let panel = MiniWindowPanel( |
| - call: Call(playAudio: false), corner: .topLeft, screen: screen, |
| - leave: {}, open: {}, moved: {}) |
| + let panel = MiniWindowPanel(call: Call(playAudio: false), actions: MiniWindowActions()) |
| XCTAssertEqual(panel.sharingType, .none) |
| XCTAssertEqual(panel.level, .floating) |
| XCTAssertFalse(panel.hidesOnDeactivate) |
| XCTAssertTrue(panel.styleMask.contains(.nonactivatingPanel)) |
| XCTAssertTrue(panel.collectionBehavior.contains(.canJoinAllSpaces)) |
| XCTAssertTrue(panel.collectionBehavior.contains(.fullScreenAuxiliary)) |
| - XCTAssertEqual( |
| - panel.frame, MiniWindowCorner.topLeft.frame(size: MiniWindowRules.size, in: screen.visibleFrame)) |
| - panel.place(.bottomRight, on: screen, animated: false) |
| - XCTAssertEqual( |
| - panel.frame, |
| - MiniWindowCorner.bottomRight.frame(size: MiniWindowRules.size, in: screen.visibleFrame)) |
| + XCTAssertTrue(panel.canBecomeKey) |
| + XCTAssertTrue(panel.becomesKeyOnlyIfNeeded, "clicking Mute must not take the keyboard") |
| + let visible = screen.visibleFrame |
| + let big = MiniWindowRules.size(width: 480, in: visible) |
| + let frame = MiniWindowCorner.topLeft.frame(size: big, in: visible) |
| + panel.expand(to: frame, animated: false) |
| + XCTAssertEqual(panel.frame, frame) |
| + XCTAssertFalse(panel.collapsed) |
| + |
| + // The tab is the same window: it keeps every one of those settings. |
| + let tab = MiniWindowTab.dock(frame, in: visible).1 |
| + panel.collapse(to: tab, expanded: frame) |
| + XCTAssertTrue(panel.collapsed) |
| + XCTAssertEqual(panel.frame, tab) |
| + XCTAssertEqual(panel.expandedFrame, frame, "the size to come back to") |
| + XCTAssertEqual(panel.sharingType, .none) |
| + XCTAssertEqual(panel.level, .floating) |
| + XCTAssertTrue(panel.collectionBehavior.contains(.canJoinAllSpaces)) |
| + panel.expand(to: panel.expandedFrame, animated: false) |
| + XCTAssertEqual(panel.frame, frame) |
| panel.dismiss() |
| XCTAssertNil(panel.contentView) |
| XCTAssertFalse(panel.isVisible) |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index 24f96f6..11651d7 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -222,6 +222,11 @@ struct PhoneCall: View { |
| call.muted ? "Unmute microphone" : "Mute microphone", |
| systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| ) { call.mute() } |
| + // Space on a hardware keyboard (iPad, mostly). It is a |
| + // UIKeyCommand without wantsPriorityOverSystemBehavior, so a |
| + // text field being typed in and a control with keyboard focus |
| + // keep their Space; only otherwise does it mute. |
| + .keyboardShortcut(.space, modifiers: []) |
| .buttonStyle( |
| CallActionStyle( |
| color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 399a780..d3f071f 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -1332,6 +1332,87 @@ const run = { |
| await ctxB.close(); |
| }, |
| + // Space toggles mute (the keyboard path to the same toggle as the |
| + // button, so the peer's badge follows over the same `media` report). |
| + // Held Space is one toggle, not a flicker; Space on the focused Mute |
| + // button is the button's own press, once; and Space in the chat |
| + // composer is a space in the message, with the microphone left alone. |
| + async spacemute() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + |
| + const pair = await assertPair(pageA, pageB, 'member A', 'member B'); |
| + if (!pair) return; |
| + const a = pair.a.peer; |
| + const pressed = () => pageA.evaluate(() => document.getElementById('mute').getAttribute('aria-pressed')); |
| + const known = await until(async () => { |
| + const s = await pageB.evaluate(() => window.meet.state().remoteMedia); |
| + return s[a] && s[a].muted === false ? s : false; |
| + }, 10000); |
| + check(known, 'B has A’s first report, unmuted (' + JSON.stringify(known) + ')'); |
| + // Whether the page kept the key: read after room.js's own listener. |
| + await pageA.evaluate(() => { |
| + window.__spacePrevented = []; |
| + window.addEventListener('keydown', (ev) => { |
| + if (ev.key === ' ') window.__spacePrevented.push(ev.defaultPrevented); |
| + }); |
| + }); |
| + |
| + await pageA.evaluate(() => document.activeElement && document.activeElement.blur()); |
| + await pageA.keyboard.press('Space'); |
| + const muted = await until(async () => { |
| + const m = await tileMarks(pageB, a); |
| + return m && m.muted ? m : false; |
| + }, 10000); |
| + check(muted, 'Space mutes A, and B sees the badge (' + JSON.stringify(muted || (await tileMarks(pageB, a))) + ')'); |
| + check((await pressed()) === 'true', 'A’s Mute button says pressed'); |
| + const said = await until(async () => { |
| + const t = await mediaLive(pageA); |
| + return t === 'Microphone muted.' ? t : false; |
| + }, 5000); |
| + check(said, 'A’s live region says so (' + (await mediaLive(pageA)) + ')'); |
| + check((await pageA.evaluate(() => window.__spacePrevented.slice(-1)[0])) === true, |
| + 'a handled Space is prevented, so the page does not scroll'); |
| + |
| + // Held: the first press toggles, the repeat is swallowed. |
| + await pageA.keyboard.down('Space'); |
| + await pageA.keyboard.down('Space'); |
| + await pageA.keyboard.up('Space'); |
| + check((await pressed()) === 'false', 'a held Space unmutes once and does not flicker back'); |
| + const unmuted = await until(async () => { |
| + const m = await tileMarks(pageB, a); |
| + return m && !m.muted ? m : false; |
| + }, 10000); |
| + check(unmuted, 'B sees A unmuted (' + JSON.stringify(unmuted || (await tileMarks(pageB, a))) + ')'); |
| + |
| + // Space on the focused button: the button's own press, exactly once. |
| + await pageA.focus('#mute'); |
| + await pageA.keyboard.press('Space'); |
| + check(await until(async () => (await pressed()) === 'true', 3000), |
| + 'Space on the focused Mute button presses it once — twice would leave it unmuted'); |
| + |
| + // Space in the chat composer is typing. |
| + check(await until(async () => !(await pageA.locator('#chat').isHidden()), 10000), 'A’s chat panel is up'); |
| + await pageA.click('#chat-text'); |
| + await pageA.keyboard.type('two words'); |
| + const typed = await pageA.inputValue('#chat-text'); |
| + check(typed === 'two words', 'Space in the chat box types a space (' + JSON.stringify(typed) + ')'); |
| + check((await pressed()) === 'true', 'and leaves the microphone as it was'); |
| + check((await pageA.evaluate(() => window.__spacePrevented.slice(-1)[0])) === false, |
| + 'a Space that is typing is not prevented'); |
| + const stillMuted = await tileMarks(pageB, a); |
| + check(stillMuted && stillMuted.muted, 'B still sees A muted'); |
| + |
| + check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')'); |
| + check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')'); |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| // Changing a device without leaving the call. The panel opens, the |
| // microphone and then the camera are chosen again from the list, and |
| // the far side's media is watched across both. |
diff --git a/test/browser/room-space.test.mjs b/test/browser/room-space.test.mjs| new file mode 100644 |
| index 0000000..1ba13ff |
| --- /dev/null |
| +++ b/test/browser/room-space.test.mjs |
| @@ -0,0 +1,117 @@ |
| +// room-space.test.mjs — Space toggles mute in the room, gated without a |
| +// browser. |
| +// |
| +// node --test test/browser/room-space.test.mjs |
| +// |
| +// Same trick as room-mute.test.mjs: the functions are lifted out of the |
| +// shipped room.js by name, so a rename fails here loudly instead of |
| +// passing against a copy. |
| +// |
| +// spaceKeyAction 'toggle', 'swallow' for a repeat, or '' when the key |
| +// is not ours |
| +// spaceBelongsTo whether Space already means something to the |
| +// focused element (typing, activation, opening) |
| +import { test } from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { readFileSync } from 'node:fs'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| +const src = readFileSync(roomJS, 'utf8'); |
| + |
| +function lift(source, name) { |
| + const start = source.indexOf('function ' + name + '('); |
| + assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); |
| + let depth = 0; |
| + for (let i = source.indexOf('{', start); i < source.length; i++) { |
| + if (source[i] === '{') depth += 1; |
| + else if (source[i] === '}') { |
| + depth -= 1; |
| + if (depth === 0) return source.slice(start, i + 1); |
| + } |
| + } |
| + assert.fail('unbalanced braces reading ' + name + ' out of room.js'); |
| +} |
| + |
| +// eslint-disable-next-line no-new-func |
| +const spaceKeyAction = new Function( |
| + lift(src, 'spaceBelongsTo') + '\n' + lift(src, 'spaceKeyAction') + '; return spaceKeyAction;', |
| +)(); |
| + |
| +// A stand-in for a focused element: a tag, attributes, and whether it is |
| +// editable, which is all the rules read. |
| +function el(tagName, attrs = {}, isContentEditable = false) { |
| + return { |
| + tagName, |
| + isContentEditable, |
| + hasAttribute: (name) => Object.prototype.hasOwnProperty.call(attrs, name), |
| + getAttribute: (name) => (Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null), |
| + }; |
| +} |
| + |
| +const space = (extra = {}) => ({ key: ' ', code: 'Space', repeat: false, ...extra }); |
| +const body = el('BODY'); |
| + |
| +test('Space on the page toggles mute', () => { |
| + assert.equal(spaceKeyAction(space(), body), 'toggle'); |
| + assert.equal(spaceKeyAction(space(), el('DIV')), 'toggle'); |
| + assert.equal(spaceKeyAction(space(), el('VIDEO')), 'toggle', 'a tile is not a control'); |
| + assert.equal(spaceKeyAction(space(), null), 'toggle'); |
| + // Either half of the key's identity is enough: some layouts report a |
| + // different key for the physical Space bar, and some synthetic events |
| + // carry no code. |
| + assert.equal(spaceKeyAction({ key: ' ', repeat: false }, body), 'toggle'); |
| + assert.equal(spaceKeyAction({ key: 'Unidentified', code: 'Space', repeat: false }, body), 'toggle'); |
| +}); |
| + |
| +test('a held Space is handled once, and its repeats are swallowed', () => { |
| + assert.equal(spaceKeyAction(space({ repeat: true }), body), 'swallow'); |
| +}); |
| + |
| +test('other keys and modified Space are not ours', () => { |
| + assert.equal(spaceKeyAction({ key: 'm', code: 'KeyM', repeat: false }, body), ''); |
| + assert.equal(spaceKeyAction({ key: 'Enter', code: 'Enter', repeat: false }, body), ''); |
| + for (const mod of ['ctrlKey', 'metaKey', 'altKey', 'shiftKey']) { |
| + assert.equal(spaceKeyAction(space({ [mod]: true }), body), '', mod + '+Space is a shortcut of its own'); |
| + } |
| + assert.equal(spaceKeyAction(space({ isComposing: true }), body), '', 'an input method composing text owns it'); |
| + assert.equal(spaceKeyAction(undefined, body), ''); |
| +}); |
| + |
| +test('Space is typing in every text field, including the chat composer', () => { |
| + for (const target of [ |
| + el('INPUT', { type: 'text' }), |
| + el('INPUT'), |
| + el('TEXTAREA', { id: 'chat-text' }), |
| + el('textarea'), |
| + el('DIV', {}, true), // contenteditable, or inside one |
| + el('DIV', { role: 'textbox' }), |
| + el('INPUT', { type: 'search' }), |
| + ]) { |
| + assert.equal(spaceKeyAction(space(), target), '', JSON.stringify(target.tagName)); |
| + assert.equal(spaceKeyAction(space({ repeat: true }), target), '', 'repeats in a field are typing too'); |
| + } |
| +}); |
| + |
| +test('Space is activation on buttons, links and other controls', () => { |
| + for (const target of [ |
| + el('BUTTON', { id: 'mute' }), |
| + el('BUTTON', { id: 'leave' }), |
| + el('INPUT', { type: 'checkbox' }), |
| + el('INPUT', { type: 'button' }), |
| + el('SELECT'), |
| + el('SUMMARY'), |
| + el('A', { href: '/m/abc' }), |
| + el('VIDEO', { controls: '' }), |
| + el('DIV', { role: 'button' }), |
| + el('SPAN', { role: ' Switch ' }), |
| + el('LI', { role: 'menuitem' }), |
| + el('DIV', { role: 'slider' }), |
| + ]) { |
| + assert.equal(spaceKeyAction(space(), target), '', target.tagName + ' ' + JSON.stringify(target.getAttribute('role'))); |
| + } |
| + // An anchor with no href is not a link and does nothing with Space. |
| + assert.equal(spaceKeyAction(space(), el('A')), 'toggle'); |
| + // A role that is not a control is not claimed. |
| + assert.equal(spaceKeyAction(space(), el('DIV', { role: 'region' })), 'toggle'); |
| +}); |