oficina / meet Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev b672c3dbe71dafec0b42d9ab02efbe0190beec9a parent b414033parent 6946270
16 files changed, +1577 −69
  • Makefile +1 −0
  • internal/calls/static/room.js +56 −1
  • internal/callstest/browser_test.go +1 −0
  • native/README.md +26 −2
  • native/apple/Sources/CallsInterop/LobbySnapshots.swift +1 −0
  • native/apple/Sources/CallsInterop/MiniWindowSnapshots.swift +32 −0
  • native/apple/Sources/CallsKit/CallsSettingsView.swift +32 −2
  • native/apple/Sources/CallsKit/MacCallView.swift +3 −0
  • native/apple/Sources/CallsKit/MiniWindow.swift +354 −55
  • native/apple/Sources/CallsKit/MiniWindowRules.swift +334 −0
  • native/apple/Sources/CallsKit/SpaceToMute.swift +132 −0
  • native/apple/Sources/CallsMac/CallsMac.swift +18 −0
  • native/apple/Tests/CallsKitTests/MiniWindowTests.swift +384 −9
  • native/ios/Sources/CallsPhone.swift +5 −0
  • test/browser/call.mjs +81 −0
  • test/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');
+});