Merge branch 'fix/call-controls-contrast' into main
Legible call controls over video (one dark overlay system, WCAG-checked), controls that stay 3 s and linger 1.5 s after the pointer leaves, no title bar in the Mac's full-window call with the traffic lights over the picture and a drag strip, and Calls 0.3.5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 files changed,
+890
−158
native/DESIGN.md+29 −9native/apple/Sources/CallsInterop/CallSnapshots.swift+13 −2native/apple/Sources/CallsKit/Call.swift+8 −0native/apple/Sources/CallsKit/CallActionStyle.swift+158 −7native/apple/Sources/CallsKit/CallChrome.swift+47 −17native/apple/Sources/CallsKit/CallFixtures.swift+75 −0native/apple/Sources/CallsKit/MacCallView.swift+30 −9native/apple/Sources/CallsKit/MacFullWindowCall.swift+227 −48native/apple/Sources/CallsKit/MutedBadge.swift+4 −2native/apple/Sources/CallsMac/CallsMac.swift+66 −2native/apple/Tests/CallsKitTests/CallChromeTests.swift+157 −32native/ios/Sources/CallsPhone.swift+2 −1native/ios/Sources/PhoneCallFixture.swift+8 −1native/ios/Sources/PhoneFullScreenCall.swift+35 −26native/ios/UITests/CallsPhoneUITests.swift+30 −1native/ios/project.yml+1 −1
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ok amadan.net/oficina/calls/callscore (cached) ? amadan.net/oficina/calls/cmd/calls [no test files] ok amadan.net/oficina/calls/internal/calls 1.401s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest (cached) ? amadan.net/oficina/calls/protocol [no test files] === migration-check === go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree === race === CGO_ENABLED=1 go test -race ./internal/... ok amadan.net/oficina/calls/internal/calls 8.343s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest (cached)
diff --git a/native/DESIGN.md b/native/DESIGN.md| index bcf520e..1dab73a 100644 |
| --- a/native/DESIGN.md |
| +++ b/native/DESIGN.md |
| @@ -91,19 +91,28 @@ Full window is the default. The mini window and tab are unchanged. |
| - **When the controls show** is `ControlsVisibility` (CallChrome.swift), one |
| state machine for both apps with the time passed in, so tests drive it. Mac: |
| - pointer movement shows them, 1.5 s still hides them, leaving the window hides |
| - them at once, and the pointer hides with them over the picture |
| - (`NSCursor.setHiddenUntilMouseMoves`). iPhone: a tap toggles, 2.5 s after the |
| - last touch hides — no hover says the person is still there, and a thumb needs |
| - the extra second to reach the bar. They stay up while the pointer is on the |
| + pointer movement shows them, 3 s still hides them (`pointerDelay`; 1.5 s in |
| + 0.3.4 was too quick), leaving the window hides them 1.5 s later |
| + (`pointerExitDelay`, never later than they were due anyway), and the pointer |
| + hides with them over the picture (`NSCursor.setHiddenUntilMouseMoves`). |
| + iPhone: a tap toggles, 3 s after the last touch hides (`touchDelay`). They stay up while the pointer is on the |
| bar, a menu, popover, sheet or panel is open (AppKit's menu-tracking and |
| window-sheet notifications cover the ones SwiftUI does not report), a bar |
| control has keyboard focus, VoiceOver is running, or an alert shows. Tab |
| - reveals them. Space and ⌘⇧M work with them hidden. Fades are opacity only; |
| - Reduce Motion shortens them to 0.1 s and panels fade instead of sliding. |
| + reveals them. Space and ⌘⇧M work with them hidden. Fades are opacity only, |
| + eased out: 0.35 s going, 0.2 s coming back; Reduce Motion shortens them to a |
| + 0.1 s opacity change and panels fade instead of sliding. |
| - **The stage** is edge to edge: square tiles for Focus and a screen, a grid |
| that fits every tile without scrolling until they would be under 200 pt |
| - (`GridFit`). On Mac the title bar is transparent with the traffic lights kept. |
| + (`GridFit`). On Mac there is no title bar while the call is full window |
| + (0.3.5): the picture goes to the window's top edge, the traffic lights sit |
| + over it and fade with the controls (not in full screen, where the system |
| + shows them), a shade behind them keeps them legible, and a 32 pt strip |
| + across the top drags the window and double-clicks as the person's title-bar |
| + setting says (`WindowDragStrip`, `TitleBarDoubleClick`). The title pill sits |
| + to the right of the traffic lights and the notices in a column beneath it. |
| + `CallWindowChrome` sets the window's title bar, watches it so SwiftUI cannot |
| + quietly restore it mid-call, and puts it back for the lobby and Classic. |
| Names are small capsules with the muted mark (shape and word) inside, lifted |
| clear of the bar while it shows, visible while it does not. |
| - **The self view** floats in Focus, draggable, settling in the corner it was |
| @@ -115,7 +124,18 @@ Full window is the default. The mini window and tab are unchanged. |
| waiting to be let in, full, ended (with Join again), problems (dismissible), |
| "You are sharing your screen" and each person waiting with **Let in** stay |
| until resolved (`CallNotices`); the relay-floor line fades. |
| -- **Mac bar**: a material bar along the bottom — microphone, camera, share, |
| +- **Over the picture** (0.3.5): one dark system, whatever the system |
| + appearance, because what is underneath is video (`CallOverlay`, |
| + `CallOverlaySurface`, `CallOverlayButtonStyle`). Every surface — the bar, |
| + the title pill, notices, name labels, the iPhone's round buttons — is black |
| + at 70 % over a dark blur with a hairline edge; the black alone holds the |
| + contrast, so white on it is at least 8.5:1 even over a white wall. Buttons |
| + are white symbols on a light fill (5.5:1 worst case); a control that is on |
| + (sharing, a panel open) is near-white with a dark symbol; microphone or |
| + camera off is amber with the slashed symbol; Leave is a deeper red than the |
| + system's (5.0:1 for its symbol). `CallOverlayContrastTests` holds the ratios |
| + over white, black and grey. Classic keeps its own look. |
| +- **Mac bar**: a dark bar along the bottom — microphone, camera, share, |
| devices, layout (Focus/Grid and who is on the stage, in a popover), share link |
| — with chat and Leave on the right. Devices is a floating panel over the |
| stage above the bar, not a column that shrinks it. |
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift| index a45d1e0..5ce2c9c 100644 |
| --- a/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| @@ -16,15 +16,17 @@ import SwiftUI |
| let suffix = LobbySnapshots.name(scheme) |
| func shot( |
| _ name: String, _ fixture: CallFixture, grid: Bool = false, shown: Bool = true, |
| - devices: Bool = false, chat: Bool = false, style: CallLayoutStyle = .fullWindow |
| + devices: Bool = false, chat: Bool = false, style: CallLayoutStyle = .fullWindow, |
| + backdrop: FixtureBackdrop? = nil, me: FixtureSelf? = nil |
| ) async throws { |
| let call = Call(playAudio: false) |
| fixture.apply(to: call) |
| + me?.apply(to: call) |
| try await LobbySnapshots.render( |
| MacCallView( |
| call: call, grid: grid, showingChat: chat, showingDevices: devices, style: style, |
| controlsShown: shown, leave: {}, rejoin: {} |
| - ).tint(CallsAppearance.accent), |
| + ).tint(CallsAppearance.accent).environment(\.fixtureBackdrop, backdrop), |
| scheme: scheme, size: size, |
| to: folder.appendingPathComponent("mac-\(name)-\(suffix).png")) |
| } |
| @@ -39,6 +41,15 @@ import SwiftUI |
| try await shot("notices-hidden", .notices, shown: false) |
| try await shot("classic-focus", .focus, style: .classic) |
| try await shot("classic-grid", .grid, grid: true, style: .classic) |
| + // The overlay over the worst pictures there are for it, in each of |
| + // your own states: it should look the same in light and dark. |
| + for backdrop in FixtureBackdrop.allCases { |
| + for me in FixtureSelf.allCases { |
| + try await shot("contrast-\(backdrop.rawValue)-\(me.rawValue)", .focus, backdrop: backdrop, me: me) |
| + } |
| + try await shot("contrast-\(backdrop.rawValue)-notices", .notices, backdrop: backdrop) |
| + } |
| + try await shot("contrast-busy-hidden", .focus, shown: false, backdrop: .busy) |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 12c1697..8dece0a 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -662,4 +662,12 @@ extension Call { |
| self.muted = muted |
| self.floor = floor |
| } |
| + |
| + /// Your own microphone, camera and screen, set over a fixture for the |
| + /// snapshots of the controls in each state. |
| + @_spi(Fixtures) public func showFixtureSelf(muted: Bool, cameraOff: Bool, sharingScreen: Bool) { |
| + self.muted = muted |
| + self.cameraOff = cameraOff |
| + self.sharingScreen = sharingScreen |
| + } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallActionStyle.swift b/native/apple/Sources/CallsKit/CallActionStyle.swift| index 2d99e34..979f80e 100644 |
| --- a/native/apple/Sources/CallsKit/CallActionStyle.swift |
| +++ b/native/apple/Sources/CallsKit/CallActionStyle.swift |
| @@ -1,22 +1,173 @@ |
| import SwiftUI |
| /// Compact call controls retain their Button labels for VoiceOver and tooltips. |
| +/// |
| +/// With `overlay` set, the button takes the look of controls over video |
| +/// (see `CallOverlay`) in that state, and `color` and `emphasized` are |
| +/// not used: the Mac's control row is one set of buttons in two places, |
| +/// the classic layout's row and the full-window call's bar. |
| public struct CallActionStyle: ButtonStyle { |
| public var color: Color |
| public var emphasized: Bool |
| + public var overlay: CallOverlayButtonStyle.Tone? |
| @Environment(\.isEnabled) private var isEnabled |
| - public init(color: Color = CallsAppearance.accent, emphasized: Bool = false) { |
| + public init( |
| + color: Color = CallsAppearance.accent, emphasized: Bool = false, |
| + overlay: CallOverlayButtonStyle.Tone? = nil |
| + ) { |
| self.color = color |
| self.emphasized = emphasized |
| + self.overlay = overlay |
| + } |
| + |
| + @ViewBuilder public func makeBody(configuration: Configuration) -> some View { |
| + if let overlay { |
| + OverlayButtonBody( |
| + label: configuration.label, tone: overlay, size: 44, round: false, |
| + pressed: configuration.isPressed, enabled: isEnabled) |
| + } else { |
| + configuration.label.labelStyle(.iconOnly).font(.title3) |
| + .frame(width: 44, height: 44) |
| + .foregroundStyle(emphasized ? .white : color) |
| + .background(emphasized ? color : color.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) |
| + .opacity(!isEnabled ? 0.4 : configuration.isPressed ? 0.65 : 1) |
| + .contentShape(RoundedRectangle(cornerRadius: 14)) |
| + } |
| + } |
| +} |
| + |
| +// MARK: - Over the picture |
| + |
| +/// The look of everything the full-window call puts over its video — the |
| +/// control bar and its buttons, the title pill, the name labels and the |
| +/// notices — as one system. It is always dark, whatever the system |
| +/// appearance, because what is under it is always video, and video can be |
| +/// anything from a black room to a white wall. |
| +/// |
| +/// Each surface is black at `scrim` over a blur. The blur only softens; |
| +/// the black does the work, so the contrast holds even where the blur is |
| +/// not drawn (an off-screen snapshot) and even over pure white, the worst |
| +/// case: white under 70 % black is #4D4D4D, so white on the bar is 8.5:1 |
| +/// at worst and 21:1 at best (over black). The ratios below are measured |
| +/// against that worst case; `CallOverlayContrastTests` holds them. |
| +public enum CallOverlay { |
| + /// The black laid over whatever the surface sits on. |
| + public static let scrim: Double = 0.7 |
| + /// A resting button's fill: white, lightly. Over the worst-case bar it |
| + /// is #696969, and a white symbol on it is 5.5:1. |
| + public static let buttonFill = Color.white.opacity(0.16) |
| + /// A hairline round a resting button and round the surfaces, so their |
| + /// edge shows over black too, where the scrim alone would vanish. |
| + public static let edge = Color.white.opacity(0.18) |
| + /// A control that is on — a panel open, the screen being shared: a |
| + /// near-white fill with a near-black symbol, 15:1 between them and 7.5:1 |
| + /// from the bar at its lightest. |
| + public static let onFill = Color(white: 0.94) |
| + public static let onSymbol = Color(white: 0.1) |
| + /// Microphone or camera off: amber, with the slashed symbol in white |
| + /// (5.7:1). A warning that others cannot hear or see you, and distinct |
| + /// from Leave's red and from the white of a control that is on. |
| + public static let offFill = Color(red: 0.65, green: 0.30, blue: 0.02) |
| + /// Leave and End call: a red deep enough for white on it to read (5.0:1; |
| + /// the system red would be 3.4:1). |
| + public static let leaveFill = Color(red: 0.84, green: 0.16, blue: 0.14) |
| + /// The unread count on Chat: white on violet, 6.0:1. |
| + public static let badgeFill = Color(red: 0.49, green: 0.23, blue: 0.85) |
| + /// Secondary text on a surface: the status line, a quiet notice. 6.0:1 |
| + /// over the worst-case surface. |
| + public static let secondaryText = Color.white.opacity(0.78) |
| + /// A notice that must be seen (you are sharing your screen): a light |
| + /// amber, 4.9:1 over the worst-case surface. |
| + public static let alertText = Color(red: 1.0, green: 0.72, blue: 0.35) |
| +} |
| + |
| +/// A surface over the picture: black at `CallOverlay.scrim` over a dark |
| +/// blur, with a hairline edge. Dark whatever the system appearance; only |
| +/// the background is made dark, so a sheet or popover the content opens |
| +/// keeps the person's own appearance. |
| +public struct CallOverlaySurface<S: InsettableShape>: ViewModifier { |
| + let shape: S |
| + public init(_ shape: S) { self.shape = shape } |
| + public func body(content: Content) -> some View { |
| + content |
| + .background { |
| + ZStack { |
| + shape.fill(.ultraThinMaterial) |
| + shape.fill(Color.black.opacity(CallOverlay.scrim)) |
| + } |
| + .environment(\.colorScheme, .dark) |
| + } |
| + .overlay(shape.strokeBorder(CallOverlay.edge, lineWidth: 1)) |
| + } |
| +} |
| + |
| +extension View { |
| + /// See `CallOverlaySurface`. |
| + public func callOverlaySurface<S: InsettableShape>(_ shape: S) -> some View { |
| + modifier(CallOverlaySurface(shape)) |
| + } |
| +} |
| + |
| +/// A button over the picture, in one of four states. The state is always |
| +/// the symbol as well as the fill: a muted microphone is slashed and amber, |
| +/// a control that is on is white with a dark symbol. |
| +public struct CallOverlayButtonStyle: ButtonStyle { |
| + public enum Tone: Sendable { |
| + /// Resting: a white symbol on a light fill. |
| + case normal |
| + /// On: a panel open, the screen being shared. |
| + case on |
| + /// Microphone or camera off. |
| + case off |
| + /// Leave, End call. |
| + case leave |
| + } |
| + |
| + public var tone: Tone |
| + public var size: CGFloat |
| + public var round: Bool |
| + @Environment(\.isEnabled) private var isEnabled |
| + |
| + public init(_ tone: Tone = .normal, size: CGFloat = 44, round: Bool = false) { |
| + self.tone = tone |
| + self.size = size |
| + self.round = round |
| } |
| public func makeBody(configuration: Configuration) -> some View { |
| - configuration.label.labelStyle(.iconOnly).font(.title3) |
| - .frame(width: 44, height: 44) |
| - .foregroundStyle(emphasized ? .white : color) |
| - .background(emphasized ? color : color.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) |
| - .opacity(!isEnabled ? 0.4 : configuration.isPressed ? 0.65 : 1) |
| - .contentShape(RoundedRectangle(cornerRadius: 14)) |
| + OverlayButtonBody( |
| + label: configuration.label, tone: tone, size: size, round: round, |
| + pressed: configuration.isPressed, enabled: isEnabled) |
| + } |
| +} |
| + |
| +private struct OverlayButtonBody<Label: View>: View { |
| + let label: Label |
| + let tone: CallOverlayButtonStyle.Tone |
| + let size: CGFloat |
| + let round: Bool |
| + let pressed: Bool |
| + let enabled: Bool |
| + |
| + var body: some View { |
| + let shape = RoundedRectangle(cornerRadius: round ? size / 2 : 14, style: .continuous) |
| + label.labelStyle(.iconOnly) |
| + .font(round ? .title2.weight(.semibold) : .title3.weight(.medium)) |
| + .frame(width: size, height: size) |
| + .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white) |
| + .background(fill, in: shape) |
| + .overlay { if tone == .normal { shape.strokeBorder(CallOverlay.edge, lineWidth: 1) } } |
| + .opacity(!enabled ? 0.45 : pressed ? 0.7 : 1) |
| + .contentShape(shape) |
| + } |
| + |
| + private var fill: Color { |
| + switch tone { |
| + case .normal: CallOverlay.buttonFill |
| + case .on: CallOverlay.onFill |
| + case .off: CallOverlay.offFill |
| + case .leave: CallOverlay.leaveFill |
| + } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallChrome.swift b/native/apple/Sources/CallsKit/CallChrome.swift| index df456f5..86ede6f 100644 |
| --- a/native/apple/Sources/CallsKit/CallChrome.swift |
| +++ b/native/apple/Sources/CallsKit/CallChrome.swift |
| @@ -60,17 +60,28 @@ public struct ControlsVisibility: Equatable, Sendable { |
| public static let alert = Holds(rawValue: 1 << 4) |
| } |
| - /// A Mac hides its controls 1.5 s after the pointer stops. The pointer |
| - /// is still there, and moving it a millimetre brings them back, so the |
| - /// delay only has to outlast the pause between a glance and a click. |
| - public static let pointerDelay: TimeInterval = 1.5 |
| - /// A touch screen waits 2.5 s. There is no hover to say the person is |
| - /// still looking: a tap is the only sign, and after it the thumb still |
| - /// has to find its target and the eyes have to read the bar — at 1.5 s |
| - /// the bar would go mid-reach. Apple's own players wait about three; |
| - /// 2.5 keeps the picture uncluttered a little sooner without racing |
| - /// the thumb. |
| - public static let touchDelay: TimeInterval = 2.5 |
| + /// A Mac keeps its controls fully up for 3 s after the pointer last |
| + /// moved. At 1.5 s (0.3.4) they went while the person was still reading |
| + /// them; three is what QuickTime and FaceTime give, and moving the |
| + /// pointer a millimetre still brings them straight back. |
| + public static let pointerDelay: TimeInterval = 3 |
| + /// A touch screen waits 3 s after the last tap. There is no hover to say |
| + /// the person is still looking: a tap is the only sign, and after it the |
| + /// thumb still has to find its target and the eyes have to read the bar. |
| + public static let touchDelay: TimeInterval = 3 |
| + /// When the pointer leaves the window the controls linger 1.5 s rather |
| + /// than going at once: a pointer that overshoots the edge, or crosses |
| + /// out and back on its way to the Dock, should not blink them. Leaving |
| + /// only ever shortens the wait — controls due to go sooner still do. |
| + public static let pointerExitDelay: TimeInterval = 1.5 |
| + /// How long the controls take to fade out: long enough to be seen going, |
| + /// eased out so the change starts at once and settles. |
| + public static let fadeOutDuration: TimeInterval = 0.35 |
| + /// How long they take to come back: quicker, since the person asked. |
| + public static let fadeInDuration: TimeInterval = 0.2 |
| + /// With Reduce Motion: a short opacity change rather than none, so the |
| + /// change is still noticed without anything easing across the screen. |
| + public static let reducedMotionFadeDuration: TimeInterval = 0.1 |
| public let input: Input |
| public private(set) var visible: Bool |
| @@ -98,13 +109,15 @@ public struct ControlsVisibility: Equatable, Sendable { |
| reveal(now: now) |
| } |
| - /// The pointer left the window: hide at once, unless something holds |
| - /// the controls up. Leaving the window is also leaving the bar. |
| + /// The pointer left the window: hide `pointerExitDelay` later (or when |
| + /// they were due already, if sooner), unless something holds the |
| + /// controls up. Leaving the window is also leaving the bar. |
| public mutating func pointerExited(now: TimeInterval) { |
| pointerInside = false |
| holds.remove(.pointerOverControls) |
| - if held { return } |
| - hide() |
| + guard visible, !held else { return } |
| + let linger = now + Self.pointerExitDelay |
| + hideAt = min(hideAt ?? linger, linger) |
| } |
| /// A tap on the picture (touch). It toggles: a tap shows hidden |
| @@ -154,10 +167,22 @@ public struct ControlsVisibility: Equatable, Sendable { |
| /// slide; with Reduce Motion a short cross-fade rather than none, so |
| /// the change is still noticed without anything moving. |
| public static func fadeDuration(showing: Bool, reduceMotion: Bool) -> TimeInterval { |
| - if reduceMotion { return 0.1 } |
| - return showing ? 0.2 : 0.35 |
| + if reduceMotion { return reducedMotionFadeDuration } |
| + return showing ? fadeInDuration : fadeOutDuration |
| + } |
| + |
| + /// The fade itself: ease-out, so it starts at once and settles; with |
| + /// Reduce Motion a short linear opacity change. |
| + public static func fade(showing: Bool, reduceMotion: Bool) -> Animation { |
| + let duration = fadeDuration(showing: showing, reduceMotion: reduceMotion) |
| + return reduceMotion ? .linear(duration: duration) : .easeOut(duration: duration) |
| } |
| + /// Whether a Mac call window's traffic lights show: with the controls, |
| + /// so the picture is clean when they go, as in QuickTime — except in |
| + /// full screen, where the system shows them with the menu bar. |
| + public func showsWindowButtons(fullScreen: Bool) -> Bool { fullScreen || visible } |
| + |
| private mutating func reveal(now: TimeInterval) { |
| visible = true |
| hideAt = held ? nil : now + delay |
| @@ -186,6 +211,11 @@ public struct ControlsVisibility: Equatable, Sendable { |
| } |
| public var visible: Bool { pinned ?? state.visible } |
| + /// See `ControlsVisibility.showsWindowButtons(fullScreen:)`; a fixture's |
| + /// pin decides for the traffic lights as for the controls. |
| + public func showsWindowButtons(fullScreen: Bool) -> Bool { |
| + fullScreen || visible |
| + } |
| public func pointerMoved() { update { $0.pointerMoved(now: Self.now) } } |
| public func pointerExited() { update { $0.pointerExited(now: Self.now) } } |
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift| index 2bb5f7a..3754da1 100644 |
| --- a/native/apple/Sources/CallsKit/CallFixtures.swift |
| +++ b/native/apple/Sources/CallsKit/CallFixtures.swift |
| @@ -1,4 +1,5 @@ |
| import Foundation |
| +import SwiftUI |
| /// Described calls for the offline snapshots of the call views (Mac, from |
| /// CallsInterop) and the iPhone's UI test of its controls (a debug build |
| @@ -43,3 +44,77 @@ import Foundation |
| } |
| } |
| } |
| + |
| +/// What the snapshots put behind the call's overlay in place of video, to |
| +/// show that the controls read over anything: black, white, and a busy |
| +/// frame with light and dark and colour and fine detail in it. |
| +@_spi(Fixtures) public enum FixtureBackdrop: String, CaseIterable, Sendable { |
| + case black, white, busy |
| +} |
| + |
| +/// Your own state, for the snapshots of the controls in each. |
| +@_spi(Fixtures) public enum FixtureSelf: String, CaseIterable, Sendable { |
| + case normal, muted, cameraOff, sharing |
| + |
| + @MainActor public func apply(to call: Call) { |
| + call.showFixtureSelf( |
| + muted: self == .muted, cameraOff: self == .cameraOff, sharingScreen: self == .sharing) |
| + } |
| +} |
| + |
| +extension FixtureBackdrop { |
| + /// The frame itself, filling whatever it is given. |
| + @_spi(Fixtures) @MainActor @ViewBuilder public var view: some View { |
| + switch self { |
| + case .black: Color.black |
| + case .white: Color.white |
| + case .busy: BusyFrame() |
| + } |
| + } |
| +} |
| + |
| +/// A stand-in for a busy picture: a bright window and a dark room, a |
| +/// saturated poster, and stripes as fine as a patterned shirt, across the |
| +/// whole frame so the bar and the pills each land on some of everything. |
| +private struct BusyFrame: View { |
| + var body: some View { |
| + GeometryReader { geometry in |
| + let size = geometry.size |
| + ZStack { |
| + LinearGradient( |
| + colors: [Color(white: 0.95), Color(red: 0.55, green: 0.42, blue: 0.3), Color(white: 0.08)], |
| + startPoint: .topLeading, endPoint: .bottomTrailing) |
| + Rectangle().fill(Color(red: 0.98, green: 0.85, blue: 0.2)) |
| + .frame(width: size.width * 0.28, height: size.height * 0.5) |
| + .position(x: size.width * 0.2, y: size.height * 0.62) |
| + Rectangle().fill(Color(red: 0.1, green: 0.35, blue: 0.85)) |
| + .frame(width: size.width * 0.22, height: size.height * 0.7) |
| + .position(x: size.width * 0.72, y: size.height * 0.55) |
| + HStack(spacing: 6) { |
| + ForEach(0..<60, id: \.self) { index in |
| + Rectangle().fill(index.isMultiple(of: 2) ? Color.white : Color.black) |
| + .frame(width: 6) |
| + } |
| + } |
| + .frame(width: size.width * 0.45, height: size.height * 0.35) |
| + .position(x: size.width * 0.45, y: size.height * 0.88) |
| + Circle().fill(Color(red: 0.9, green: 0.2, blue: 0.3)) |
| + .frame(width: size.height * 0.3) |
| + .position(x: size.width * 0.9, y: size.height * 0.12) |
| + } |
| + .clipped() |
| + } |
| + } |
| +} |
| + |
| +private struct FixtureBackdropKey: EnvironmentKey { |
| + static let defaultValue: FixtureBackdrop? = nil |
| +} |
| + |
| +extension EnvironmentValues { |
| + /// A fixture's frame in place of the call's stage; nil in a real call. |
| + @_spi(Fixtures) public var fixtureBackdrop: FixtureBackdrop? { |
| + get { self[FixtureBackdropKey.self] } |
| + set { self[FixtureBackdropKey.self] = newValue } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index e3607bf..b8b326c 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -25,6 +25,7 @@ |
| @FocusState var focusedControl: CallControlID? |
| @Environment(\.accessibilityReduceMotion) var reduceMotion |
| @Environment(\.accessibilityVoiceOverEnabled) var voiceOver |
| + @Environment(\.fixtureBackdrop) var fixtureBackdrop |
| private let style: CallLayoutStyle? |
| /// - Parameters: |
| @@ -277,7 +278,7 @@ |
| .buttonStyle( |
| CallActionStyle( |
| color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, |
| - emphasized: call.muted) |
| + emphasized: call.muted, overlay: tone(fullWindow, call.muted ? .off : .normal)) |
| ) |
| .help(call.muted ? "Unmute microphone" : "Mute microphone") |
| .accessibilityIdentifier("mute") |
| @@ -289,7 +290,7 @@ |
| .buttonStyle( |
| CallActionStyle( |
| color: call.cameraOff ? Color(red: 0.65, green: 0.30, blue: 0.02) : .blue, |
| - emphasized: call.cameraOff) |
| + emphasized: call.cameraOff, overlay: tone(fullWindow, call.cameraOff ? .off : .normal)) |
| ) |
| .help(call.cameraOff ? "Turn camera on" : "Turn camera off") |
| .accessibilityIdentifier("camera") |
| @@ -304,7 +305,10 @@ |
| Task { await call.shareScreen(MacScreenCapture()) } |
| } |
| } |
| - .buttonStyle(CallActionStyle(color: .orange, emphasized: call.sharingScreen)) |
| + .buttonStyle( |
| + CallActionStyle( |
| + color: .orange, emphasized: call.sharingScreen, |
| + overlay: tone(fullWindow, call.sharingScreen ? .on : .normal))) |
| .help(call.sharingScreen ? "Stop sharing your screen" : "Share a window or your whole screen") |
| .disabled(call.state?.phase != "admitted") |
| .accessibilityIdentifier("shareScreen") |
| @@ -312,28 +316,36 @@ |
| Button("Devices", systemImage: "slider.horizontal.3") { |
| withAnimation(panelAnimation) { showingDevices.toggle() } |
| } |
| - .buttonStyle(CallActionStyle(color: .teal, emphasized: showingDevices)) |
| + .buttonStyle( |
| + CallActionStyle( |
| + color: .teal, emphasized: showingDevices, |
| + overlay: tone(fullWindow, showingDevices ? .on : .normal))) |
| .help("Choose the camera, microphone and speaker for this call") |
| .accessibilityIdentifier("devices") |
| .focused($focusedControl, equals: .devices) |
| if fullWindow { |
| layoutMenu.focused($focusedControl, equals: .layout) |
| } |
| - MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo)) |
| + MeetingShareButton(call: call) |
| + .buttonStyle(CallActionStyle(color: .indigo, overlay: tone(fullWindow, .normal))) |
| .help("Share meeting link") |
| .focused($focusedControl, equals: .link) |
| Spacer(minLength: fullWindow ? 24 : nil) |
| Button(chatTitle, systemImage: "bubble.left.and.bubble.right.fill") { |
| if chatWindow.isOpen { chatWindow.close() } |
| withAnimation(panelAnimation) { showingChat.toggle() } |
| - }.buttonStyle(CallActionStyle(color: .purple, emphasized: showingChat || chatWindow.isOpen)) |
| + }.buttonStyle( |
| + CallActionStyle( |
| + color: .purple, emphasized: showingChat || chatWindow.isOpen, |
| + overlay: tone(fullWindow, showingChat || chatWindow.isOpen ? .on : .normal))) |
| .help(chatTitle) |
| .accessibilityIdentifier("openChat") |
| .focused($focusedControl, equals: .chat) |
| .overlay(alignment: .topTrailing) { |
| if unread > 0 { |
| Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) |
| - .padding(4).background(.purple, in: Capsule()).offset(x: 5, y: -5) |
| + .padding(4).background(fullWindow ? CallOverlay.badgeFill : .purple, in: Capsule()) |
| + .offset(x: 5, y: -5) |
| .accessibilityHidden(true) |
| } |
| } |
| @@ -344,7 +356,8 @@ |
| } |
| } |
| Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| - .buttonStyle(CallActionStyle(color: .red, emphasized: true)).help("Leave meeting") |
| + .buttonStyle(CallActionStyle(color: .red, emphasized: true, overlay: tone(fullWindow, .leave))) |
| + .help("Leave meeting") |
| .accessibilityIdentifier("leave") |
| .focused($focusedControl, equals: .leave) |
| }.frame(maxWidth: .infinity) |
| @@ -357,7 +370,7 @@ |
| Button( |
| "Video layout", systemImage: grid ? "square.grid.2x2" : "pip" |
| ) { showingLayout.toggle() } |
| - .buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout)) |
| + .buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout, overlay: showingLayout ? .on : .normal)) |
| .help("Video layout: Focus or Grid, and who is on the main stage") |
| .accessibilityIdentifier("videoLayout") |
| .popover(isPresented: $showingLayout, arrowEdge: .top) { |
| @@ -394,6 +407,14 @@ |
| .buttonStyle(.borderless) |
| } |
| + /// The full-window bar's look for a button in `state`; the classic |
| + /// row keeps its own colours. |
| + private func tone(_ fullWindow: Bool, _ state: CallOverlayButtonStyle.Tone) |
| + -> CallOverlayButtonStyle.Tone? |
| + { |
| + fullWindow ? state : nil |
| + } |
| + |
| var panelAnimation: Animation? { |
| reduceMotion ? .linear(duration: 0.1) : .easeOut(duration: 0.2) |
| } |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index 3e2a2cf..6f2755c 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -8,11 +8,13 @@ |
| case mute, camera, share, devices, layout, link, chat, leave |
| } |
| - /// The full-window call: the picture edge to edge under the window's |
| - /// traffic lights, with the title, the controls and the panels over it. |
| - /// The controls come with the pointer and go 1.5 s after it stops, or |
| - /// as soon as it leaves the window (see ControlsVisibility for what |
| - /// keeps them up). Notices that matter stay whatever the controls do. |
| + /// The full-window call: the picture edge to edge, to the very top of |
| + /// the window, with the traffic lights, the title, the controls and the |
| + /// panels over it. There is no title bar: a strip across the top drags |
| + /// the window instead. The controls and the traffic lights come with the |
| + /// pointer and go 3 s after it stops, or 1.5 s after it leaves the |
| + /// window (see ControlsVisibility for what keeps them up). Notices that |
| + /// matter stay whatever the controls do. |
| extension MacCallView { |
| /// Room kept clear at the top for the title bar's traffic lights. |
| static let titleBarHeight: CGFloat = 32 |
| @@ -21,14 +23,28 @@ |
| static let barReserve: CGFloat = 44 + 20 + 16 |
| static let panelWidth: CGFloat = 320 |
| static let tileBackdrop = Color(white: 0.13) |
| + /// Where the traffic lights sit, from the window's top-left corner: |
| + /// the pointer over them holds the controls (and them) up, and the |
| + /// title keeps to the right of it. |
| + static let windowButtonsArea = CGRect(x: 0, y: 0, width: 80, height: titleBarHeight) |
| var immersive: some View { |
| GeometryReader { geometry in |
| let size = geometry.size |
| ZStack { |
| Color.black |
| - fullWindowStage(in: size) |
| + if let fixtureBackdrop { |
| + fixtureBackdrop.view.accessibilityHidden(true) |
| + } else { |
| + fullWindowStage(in: size) |
| + } |
| if !grid { selfView(in: size) } |
| + topShade |
| + // Above the picture, below the title and everything else: what |
| + // is left of the strip once they have their clicks drags. |
| + WindowDragStrip() |
| + .frame(height: Self.titleBarHeight) |
| + .frame(maxHeight: .infinity, alignment: .top) |
| topOverlay |
| panels(in: size) |
| bar |
| @@ -37,12 +53,18 @@ |
| .frame(width: size.width, height: size.height) |
| } |
| .ignoresSafeArea() |
| - .background(FullWindowTitleBar()) |
| + .background( |
| + CallWindowChrome( |
| + buttonsVisible: chrome.visible, |
| + fade: ControlsVisibility.fadeDuration(showing: chrome.visible, reduceMotion: reduceMotion))) |
| .background( |
| CallWindowActivity( |
| moved: { point in |
| chrome.pointerMoved() |
| - chrome.set(.pointerOverControls, chrome.visible && barFrame.contains(point)) |
| + chrome.set( |
| + .pointerOverControls, |
| + chrome.visible |
| + && (barFrame.contains(point) || Self.windowButtonsArea.contains(point))) |
| }, |
| exited: { chrome.pointerExited() }, |
| activity: { chrome.activity() }, |
| @@ -68,9 +90,20 @@ |
| } |
| private var fade: Animation { |
| - .easeInOut( |
| - duration: ControlsVisibility.fadeDuration( |
| - showing: chrome.visible, reduceMotion: reduceMotion)) |
| + ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion) |
| + } |
| + |
| + /// A shade down from the top edge while the controls show, so the |
| + /// traffic lights and the title read over a bright picture. |
| + private var topShade: some View { |
| + LinearGradient( |
| + colors: [.black.opacity(0.5), .black.opacity(0)], startPoint: .top, endPoint: .bottom |
| + ) |
| + .frame(height: 96) |
| + .frame(maxHeight: .infinity, alignment: .top) |
| + .opacity(chrome.visible ? 1 : 0) |
| + .allowsHitTesting(false) |
| + .accessibilityHidden(true) |
| } |
| private func updateHolds() { |
| @@ -239,25 +272,33 @@ |
| } |
| private var topOverlay: some View { |
| - ZStack(alignment: .top) { |
| + // A column, so a notice is never under the title however tall the |
| + // title grows; the title keeps its place while faded, so the notices |
| + // do not jump as the controls come and go. |
| + VStack(spacing: 8) { |
| // The title and how the call is going, beside the traffic lights. |
| // They go with the controls; the notices below them do not. |
| HStack { |
| VStack(alignment: .leading, spacing: 2) { |
| - Text(call.title).font(.headline).lineLimit(1) |
| - Text(call.status).font(.callout).foregroundStyle(.secondary).lineLimit(1) |
| + Text(call.title).font(.headline).foregroundStyle(.white).lineLimit(1) |
| + Text(call.status).font(.callout).foregroundStyle(CallOverlay.secondaryText) |
| + .lineLimit(1) |
| .accessibilityAddTraits(.updatesFrequently) |
| ForEach(notices.filter { !$0.important }) { notice in |
| - Text(notice.text).font(.callout).foregroundStyle(.secondary) |
| + Text(notice.text).font(.callout).foregroundStyle(CallOverlay.secondaryText) |
| .fixedSize(horizontal: false, vertical: true) |
| } |
| } |
| .padding(.horizontal, 12).padding(.vertical, 7) |
| - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) |
| + .callOverlaySurface(RoundedRectangle(cornerRadius: 12, style: .continuous)) |
| + .accessibilityElement(children: .contain) |
| + .accessibilityIdentifier("callTitle") |
| .frame(maxWidth: 420, alignment: .leading) |
| Spacer(minLength: 0) |
| } |
| - .padding(.leading, 84).padding(.top, 8).padding(.trailing, 12) |
| + // Right of the traffic lights, level with them. |
| + .padding(.leading, Self.windowButtonsArea.maxX + 4).padding(.top, 6) |
| + .padding(.trailing, 12) |
| .opacity(chrome.visible ? 1 : 0) |
| .allowsHitTesting(chrome.visible) |
| .accessibilityHidden(!chrome.visible) |
| @@ -275,7 +316,6 @@ |
| .modifier(NoticeSurface()) |
| } |
| } |
| - .padding(.top, Self.titleBarHeight + 36) |
| .padding(.horizontal, 90) |
| } |
| .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
| @@ -286,9 +326,13 @@ |
| switch notice.kind { |
| case .sharing: |
| Label(notice.text, systemImage: "rectangle.on.rectangle") |
| - .foregroundStyle(.orange).bold() |
| + .foregroundStyle(CallOverlay.alertText).bold() |
| case .reconnecting, .waitingToJoin: |
| - ProgressView().controlSize(.small) |
| + // Drawn rather than AppKit's spinner, which stays dark-on-dark |
| + // on this surface in a light-appearance app. |
| + Image(systemName: "arrow.triangle.2.circlepath") |
| + .symbolEffect(.pulse, isActive: !reduceMotion) |
| + .accessibilityHidden(true) |
| Text(notice.text) |
| case .full: |
| Label(notice.text, systemImage: "person.3.fill") |
| @@ -384,9 +428,8 @@ |
| private var bar: some View { |
| controls(fullWindow: true) |
| .padding(10) |
| - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20)) |
| - .overlay(RoundedRectangle(cornerRadius: 20).strokeBorder(.white.opacity(0.08))) |
| - .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| + .callOverlaySurface(RoundedRectangle(cornerRadius: 20, style: .continuous)) |
| + .shadow(color: .black.opacity(0.3), radius: 12, y: 4) |
| .frame(maxWidth: 760) |
| .background( |
| GeometryReader { bar in |
| @@ -416,50 +459,186 @@ |
| } |
| } |
| - /// A notice over the picture: a small material capsule. |
| + /// A notice over the picture: a capsule of the overlay's dark surface, |
| + /// with white text and dark-appearance controls, whatever the system's. |
| struct NoticeSurface: ViewModifier { |
| func body(content: Content) -> some View { |
| - content.font(.callout) |
| + content.font(.callout).foregroundStyle(.white) |
| + .environment(\.colorScheme, .dark) |
| .padding(.horizontal, 14).padding(.vertical, 8) |
| - .background(.regularMaterial, in: Capsule()) |
| - .shadow(color: .black.opacity(0.2), radius: 8, y: 2) |
| + .callOverlaySurface(Capsule()) |
| + .shadow(color: .black.opacity(0.25), radius: 8, y: 2) |
| } |
| } |
| - /// Takes the call window's title bar out of the picture's way while the |
| - /// full-window call is showing — transparent, no title, content under |
| - /// it — and puts it back after. The traffic lights stay. |
| - struct FullWindowTitleBar: NSViewRepresentable { |
| - func makeNSView(context: Context) -> TitleBarView { TitleBarView() } |
| - func updateNSView(_ view: TitleBarView, context: Context) {} |
| + /// The call window's own chrome while the full-window call shows: no |
| + /// title bar — transparent, no title, the content under it to the very |
| + /// top — and the traffic lights over the picture, fading with the |
| + /// controls. Everything is put back when the call leaves the window or |
| + /// changes to Classic, and none of it touches full screen or a panel. |
| + /// |
| + /// SwiftUI keeps its own idea of the window's title bar and can put it |
| + /// back while the call is up (0.3.4 showed an opaque bar over the call); |
| + /// so while this view is in the window it watches those properties and |
| + /// sets them again whenever something else changes them. |
| + struct CallWindowChrome: NSViewRepresentable { |
| + var buttonsVisible: Bool |
| + var fade: TimeInterval |
| + |
| + func makeNSView(context: Context) -> ChromeView { ChromeView() } |
| + func updateNSView(_ view: ChromeView, context: Context) { |
| + view.setButtons(visible: buttonsVisible, fade: fade) |
| + } |
| + static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.release() } |
| - final class TitleBarView: NSView { |
| + final class ChromeView: NSView { |
| private weak var styled: NSWindow? |
| - private var saved: (transparent: Bool, title: NSWindow.TitleVisibility, full: Bool)? |
| + private var saved: (transparent: Bool, title: NSWindow.TitleVisibility, full: Bool, separator: NSTitlebarSeparatorStyle)? |
| + private var watches: [NSKeyValueObservation] = [] |
| + private var observers: [NSObjectProtocol] = [] |
| + private var buttonsVisible = true |
| - override func viewWillMove(toWindow next: NSWindow?) { |
| - super.viewWillMove(toWindow: next) |
| - restore() |
| - } |
| override func viewDidMoveToWindow() { |
| super.viewDidMoveToWindow() |
| - guard let window, !(window is NSPanel) else { return } |
| + guard let window, !(window is NSPanel) else { |
| + release() |
| + return |
| + } |
| + guard styled !== window else { return } |
| + release() |
| + styled = window |
| saved = ( |
| window.titlebarAppearsTransparent, window.titleVisibility, |
| - window.styleMask.contains(.fullSizeContentView) |
| + window.styleMask.contains(.fullSizeContentView), window.titlebarSeparatorStyle |
| ) |
| - styled = window |
| - window.titlebarAppearsTransparent = true |
| - window.titleVisibility = .hidden |
| - window.styleMask.insert(.fullSizeContentView) |
| + apply(to: window) |
| + showButtons(animated: false) |
| + watches = [ |
| + window.observe(\.titlebarAppearsTransparent) { [weak self] window, _ in |
| + MainActor.assumeIsolated { self?.apply(to: window) } |
| + }, |
| + window.observe(\.styleMask) { [weak self] window, _ in |
| + MainActor.assumeIsolated { self?.apply(to: window) } |
| + }, |
| + ] |
| + let center = NotificationCenter.default |
| + observers = [ |
| + NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification, |
| + NSWindow.didBecomeKeyNotification, NSWindow.didResizeNotification, |
| + ].map { name in |
| + center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in |
| + MainActor.assumeIsolated { self?.showButtons(animated: false) } |
| + } |
| + } |
| + } |
| + |
| + /// Hidden title bar, content to the top. Only what differs is set, |
| + /// so the watches that call this do not call it again. |
| + private func apply(to window: NSWindow) { |
| + guard window === styled else { return } |
| + if !window.titlebarAppearsTransparent { window.titlebarAppearsTransparent = true } |
| + if window.titleVisibility != .hidden { window.titleVisibility = .hidden } |
| + if !window.styleMask.contains(.fullSizeContentView) { |
| + window.styleMask.insert(.fullSizeContentView) |
| + } |
| + if window.titlebarSeparatorStyle != .none { window.titlebarSeparatorStyle = .none } |
| + } |
| + |
| + func setButtons(visible: Bool, fade: TimeInterval) { |
| + guard visible != buttonsVisible else { return } |
| + buttonsVisible = visible |
| + NSAnimationContext.runAnimationGroup { context in |
| + context.duration = fade |
| + context.timingFunction = CAMediaTimingFunction(name: .easeOut) |
| + showButtons(animated: true) |
| + } |
| + } |
| + |
| + /// The traffic lights, shown or hidden with the controls; always |
| + /// shown in full screen, where the system reveals them with the menu |
| + /// bar, and when the call lets the window go. |
| + private func showButtons(animated: Bool) { |
| + guard let window = styled else { return } |
| + let fullScreen = window.styleMask.contains(.fullScreen) |
| + let alpha: CGFloat = buttonsVisible || fullScreen ? 1 : 0 |
| + for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { |
| + guard let button = window.standardWindowButton(kind) else { continue } |
| + if animated { button.animator().alphaValue = alpha } else { button.alphaValue = alpha } |
| + } |
| } |
| - private func restore() { |
| + |
| + /// Puts the window back as it was found. |
| + func release() { |
| + watches.forEach { $0.invalidate() } |
| + watches = [] |
| + observers.forEach(NotificationCenter.default.removeObserver) |
| + observers = [] |
| guard let window = styled, let saved else { return } |
| + styled = nil |
| + self.saved = nil |
| + for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { |
| + window.standardWindowButton(kind)?.alphaValue = 1 |
| + } |
| window.titlebarAppearsTransparent = saved.transparent |
| window.titleVisibility = saved.title |
| + window.titlebarSeparatorStyle = saved.separator |
| if !saved.full { window.styleMask.remove(.fullSizeContentView) } |
| - styled = nil |
| - self.saved = nil |
| + } |
| + |
| + override func viewWillMove(toWindow next: NSWindow?) { |
| + super.viewWillMove(toWindow: next) |
| + if next !== styled { release() } |
| + } |
| + |
| + override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| + } |
| + } |
| + |
| + /// The strip across the top of the full-window call that stands in for |
| + /// the title bar it replaces: dragging it moves the window, and a double |
| + /// click does what the person's "double-click a window's title bar" |
| + /// setting says (zoom, minimise, or nothing). |
| + struct WindowDragStrip: NSViewRepresentable { |
| + func makeNSView(context: Context) -> StripView { StripView() } |
| + func updateNSView(_ view: StripView, context: Context) {} |
| + |
| + final class StripView: NSView { |
| + override var mouseDownCanMoveWindow: Bool { true } |
| + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } |
| + |
| + override func mouseDown(with event: NSEvent) { |
| + guard let window else { return } |
| + if event.clickCount == 2 { |
| + switch TitleBarDoubleClick.current() { |
| + case .zoom: window.zoom(nil) |
| + case .minimize: window.miniaturize(nil) |
| + case .none: break |
| + } |
| + } else { |
| + window.performDrag(with: event) |
| + } |
| + } |
| + } |
| + } |
| + |
| + /// What a double click on a title bar does, from System Settings › |
| + /// Desktop & Dock: "Double-click a window's title bar to…". |
| + enum TitleBarDoubleClick: Equatable { |
| + case zoom, minimize, none |
| + |
| + static func current(_ defaults: UserDefaults = .standard) -> TitleBarDoubleClick { |
| + from(action: defaults.string(forKey: "AppleActionOnDoubleClick"), |
| + miniaturize: defaults.object(forKey: "AppleMiniaturizeOnDoubleClick") as? Bool) |
| + } |
| + |
| + /// "Maximize" (zoom) is the default, and "Fill" zooms too; the older |
| + /// boolean says minimise when the newer key is absent. |
| + static func from(action: String?, miniaturize: Bool?) -> TitleBarDoubleClick { |
| + switch action { |
| + case "Minimize": return .minimize |
| + case "None": return .none |
| + case nil: return miniaturize == true ? .minimize : .zoom |
| + default: return .zoom |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift| index 96ee2db..498ef98 100644 |
| --- a/native/apple/Sources/CallsKit/MutedBadge.swift |
| +++ b/native/apple/Sources/CallsKit/MutedBadge.swift |
| @@ -41,14 +41,16 @@ public struct TileNameLabel: View { |
| Label("Muted", systemImage: "mic.slash.fill").labelStyle(.titleAndIcon) |
| .padding(.leading, 5) |
| .overlay(alignment: .leading) { |
| - Rectangle().fill(.white.opacity(0.4)).frame(width: 1, height: 10) |
| + Rectangle().fill(.white.opacity(0.5)).frame(width: 1, height: 10) |
| } |
| } |
| } |
| .font(.caption.weight(.semibold)).foregroundStyle(.white) |
| .fixedSize() |
| .padding(.horizontal, 7).padding(.vertical, 3) |
| - .background(.black.opacity(0.55), in: Capsule()) |
| + // The overlay's dark surface, as the controls have: white on it is |
| + // 8.5:1 even over a white wall. |
| + .callOverlaySurface(Capsule()) |
| .accessibilityHidden(true) |
| } |
| } |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index 3dd13b9..cb85552 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -3,7 +3,7 @@ import RastrilloMacUpdates |
| #endif |
| import AppKit |
| @preconcurrency import CallsBridge |
| -import CallsKit |
| +@_spi(Fixtures) import CallsKit |
| import SwiftUI |
| import LiveKitWebRTC |
| @@ -134,7 +134,9 @@ struct ContentView: View { |
| let updater: NativeUpdater |
| var body: some View { |
| Group { |
| - if model.joining { |
| + if let fixture = MacCallFixtureView.requested { |
| + MacCallFixtureView(fixture: fixture) |
| + } else if model.joining { |
| // The layout pads itself: the classic view sits in a margin, the |
| // full-window one goes to the window's edges. |
| MacCallView(call: model.call, leave: model.leave, rejoin: model.join) |
| @@ -147,3 +149,65 @@ struct ContentView: View { |
| }.tint(CallsAppearance.accent) |
| } |
| } |
| + |
| +/// The full-window call with no server behind it, in the real window, to |
| +/// see the title bar, the traffic lights and the drag strip for real. |
| +/// Debug builds only: `-CallsCallFixture focus` (or grid, screen, |
| +/// notices), optionally `-CallsFixtureBackdrop black|white|busy`, |
| +/// `-CallsFixtureSelf muted|cameraOff|sharing` and `-CallsFixtureControls |
| +/// shown|hidden`; without that last, the pointer decides as in a call. |
| +struct MacCallFixtureView: View { |
| + static var requested: CallFixture? { |
| + #if DEBUG |
| + UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:)) |
| + #else |
| + nil |
| + #endif |
| + } |
| + |
| + let fixture: CallFixture |
| + @StateObject private var call = Call(playAudio: false) |
| + /// `-CallsFixtureJoinAfter <s>` starts on a stand-in for the lobby and |
| + /// joins after that long; `-CallsFixtureLeaveAfter <s>` goes back to it: |
| + /// the window's title bar has to come and go with the call. |
| + @State private var inCall = UserDefaults.standard.object(forKey: "CallsFixtureJoinAfter") == nil |
| + |
| + var body: some View { |
| + Group { |
| + if inCall { |
| + callView |
| + } else { |
| + Text("Lobby stand-in").frame(maxWidth: .infinity, maxHeight: .infinity) |
| + } |
| + } |
| + .task { |
| + let defaults = UserDefaults.standard |
| + if defaults.object(forKey: "CallsFixtureJoinAfter") != nil { |
| + try? await Task.sleep(for: .seconds(defaults.double(forKey: "CallsFixtureJoinAfter"))) |
| + inCall = true |
| + } |
| + if defaults.object(forKey: "CallsFixtureLeaveAfter") != nil { |
| + try? await Task.sleep(for: .seconds(defaults.double(forKey: "CallsFixtureLeaveAfter"))) |
| + inCall = false |
| + } |
| + } |
| + } |
| + |
| + private var callView: some View { |
| + let defaults = UserDefaults.standard |
| + return MacCallView( |
| + call: call, grid: fixture == .grid, |
| + controlsShown: defaults.string(forKey: "CallsFixtureControls").map { $0 == "shown" }, |
| + leave: {}, rejoin: {} |
| + ) |
| + .environment( |
| + \.fixtureBackdrop, |
| + defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:))) |
| + .onAppear { |
| + fixture.apply(to: call) |
| + defaults.string(forKey: "CallsFixtureSelf").flatMap(FixtureSelf.init(rawValue:))? |
| + .apply(to: call) |
| + } |
| + } |
| +} |
| + |
diff --git a/native/apple/Tests/CallsKitTests/CallChromeTests.swift b/native/apple/Tests/CallsKitTests/CallChromeTests.swift| index 84bdf06..3d453a6 100644 |
| --- a/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| @@ -12,49 +12,81 @@ final class ControlsVisibilityTests: XCTestCase { |
| func testStartsVisibleAndHidesAfterTheDelay() { |
| var pointer = V(input: .pointer, now: 0) |
| XCTAssertTrue(pointer.visible) |
| - XCTAssertEqual(pointer.hideAt, 1.5) |
| - pointer.tick(now: 1.4) |
| + XCTAssertEqual(pointer.hideAt, 3) |
| + pointer.tick(now: 2.9) |
| XCTAssertTrue(pointer.visible, "not before the delay") |
| - pointer.tick(now: 1.5) |
| + pointer.tick(now: 3) |
| XCTAssertFalse(pointer.visible) |
| XCTAssertNil(pointer.hideAt) |
| var touch = V(input: .touch, now: 10) |
| - XCTAssertEqual(touch.hideAt, 12.5, "touch waits longer") |
| - touch.tick(now: 12.4) |
| + XCTAssertEqual(touch.hideAt, 13) |
| + touch.tick(now: 12.9) |
| XCTAssertTrue(touch.visible) |
| - touch.tick(now: 12.5) |
| + touch.tick(now: 13) |
| XCTAssertFalse(touch.visible) |
| } |
| - func testTheDelaysAreOneAndAHalfAndTwoAndAHalfSeconds() { |
| - XCTAssertEqual(V.pointerDelay, 1.5) |
| - XCTAssertEqual(V.touchDelay, 2.5) |
| + /// Paul, on 0.3.4: "it should hang around for 2–3 seconds before |
| + /// fading away". |
| + func testTheTimings() { |
| + XCTAssertEqual(V.pointerDelay, 3) |
| + XCTAssertEqual(V.touchDelay, 3) |
| + XCTAssertEqual(V.pointerExitDelay, 1.5) |
| + XCTAssertEqual(V.fadeOutDuration, 0.35) |
| + XCTAssertEqual(V.fadeDuration(showing: false, reduceMotion: false), 0.35) |
| + XCTAssertEqual(V.fadeDuration(showing: true, reduceMotion: false), V.fadeInDuration) |
| } |
| func testPointerMovementShowsAndRestartsTheWait() { |
| var state = V(input: .pointer, now: 0) |
| - state.tick(now: 2) |
| + state.tick(now: 3) |
| XCTAssertFalse(state.visible) |
| - state.pointerMoved(now: 3) |
| - XCTAssertTrue(state.visible) |
| - XCTAssertEqual(state.hideAt, 4.5) |
| state.pointerMoved(now: 4) |
| - state.tick(now: 4.5) |
| - XCTAssertTrue(state.visible, "movement at 4 pushed the hide to 5.5") |
| - state.tick(now: 5.5) |
| + XCTAssertTrue(state.visible) |
| + XCTAssertEqual(state.hideAt, 7) |
| + state.pointerMoved(now: 5) |
| + state.tick(now: 7) |
| + XCTAssertTrue(state.visible, "movement at 5 pushed the hide to 8") |
| + state.tick(now: 8) |
| XCTAssertFalse(state.visible) |
| } |
| - func testLeavingTheWindowHidesAtOnce() { |
| + func testLeavingTheWindowLingersThenHides() { |
| var state = V(input: .pointer, now: 0) |
| state.pointerMoved(now: 0.2) |
| state.pointerExited(now: 0.3) |
| - XCTAssertFalse(state.visible, "no waiting out the delay") |
| + XCTAssertTrue(state.visible, "not at once") |
| + XCTAssertEqual(state.hideAt, 1.8, "1.5 s after leaving, sooner than the 3 s wait") |
| XCTAssertFalse(state.pointerInside) |
| + state.tick(now: 1.7) |
| + XCTAssertTrue(state.visible) |
| + state.tick(now: 1.8) |
| + XCTAssertFalse(state.visible) |
| XCTAssertFalse(state.hidesPointer, "no pointer to hide outside the window") |
| } |
| + func testLeavingTheWindowNeverLengthensTheWait() { |
| + var state = V(input: .pointer, now: 0) |
| + state.pointerMoved(now: 0) |
| + state.pointerExited(now: 2.5) |
| + XCTAssertEqual(state.hideAt, 3, "already due at 3") |
| + state.tick(now: 3) |
| + XCTAssertFalse(state.visible) |
| + state.pointerExited(now: 4) |
| + XCTAssertFalse(state.visible, "leaving does not bring hidden controls back") |
| + XCTAssertNil(state.hideAt) |
| + } |
| + |
| + func testComingBackBeforeTheLingerEndsKeepsThem() { |
| + var state = V(input: .pointer, now: 0) |
| + state.pointerExited(now: 1) |
| + state.pointerMoved(now: 2) |
| + state.tick(now: 2.5) |
| + XCTAssertTrue(state.visible) |
| + XCTAssertEqual(state.hideAt, 5) |
| + } |
| + |
| func testLeavingTheWindowDoesNotHideHeldControls() { |
| var state = V(input: .pointer, now: 0) |
| state.set(.menuOpen, true, now: 0.1) |
| @@ -62,8 +94,8 @@ final class ControlsVisibilityTests: XCTestCase { |
| XCTAssertTrue(state.visible, "a menu is open") |
| state.set(.menuOpen, false, now: 3) |
| XCTAssertTrue(state.visible, "letting go starts the wait rather than hiding at once") |
| - XCTAssertEqual(state.hideAt, 4.5) |
| - state.tick(now: 4.5) |
| + XCTAssertEqual(state.hideAt, 6) |
| + state.tick(now: 6) |
| XCTAssertFalse(state.visible) |
| } |
| @@ -72,6 +104,8 @@ final class ControlsVisibilityTests: XCTestCase { |
| state.set(.pointerOverControls, true, now: 0.1) |
| state.pointerExited(now: 0.2) |
| XCTAssertFalse(state.holds.contains(.pointerOverControls)) |
| + XCTAssertEqual(state.hideAt, 1.7, "the bar no longer holds them; they linger, then go") |
| + state.tick(now: 1.7) |
| XCTAssertFalse(state.visible) |
| } |
| @@ -113,14 +147,14 @@ final class ControlsVisibilityTests: XCTestCase { |
| state.tick(now: 100) |
| XCTAssertTrue(state.visible, "the alert still holds") |
| state.set(.alert, false, now: 100) |
| - state.tick(now: 101.5) |
| + state.tick(now: 103) |
| XCTAssertFalse(state.visible) |
| } |
| func testReleasingAHoldThatWasNotHeldChangesNothing() { |
| var state = V(input: .pointer, now: 0) |
| - state.tick(now: 2) |
| - state.set(.menuOpen, false, now: 3) |
| + state.tick(now: 3) |
| + state.set(.menuOpen, false, now: 4) |
| XCTAssertFalse(state.visible) |
| XCTAssertNil(state.hideAt) |
| } |
| @@ -135,6 +169,7 @@ final class ControlsVisibilityTests: XCTestCase { |
| var pointer = V(input: .pointer, now: 0) |
| pointer.set(.voiceOver, true, now: 0) |
| pointer.pointerExited(now: 1) |
| + pointer.tick(now: 1000) |
| XCTAssertTrue(pointer.visible, "nor the pointer leaving") |
| } |
| @@ -144,8 +179,10 @@ final class ControlsVisibilityTests: XCTestCase { |
| XCTAssertFalse(state.visible, "a tap on showing controls hides them") |
| state.tapped(now: 1) |
| XCTAssertTrue(state.visible) |
| - XCTAssertEqual(state.hideAt, 3.5) |
| - state.tick(now: 3.5) |
| + XCTAssertEqual(state.hideAt, 4) |
| + state.tick(now: 3.9) |
| + XCTAssertTrue(state.visible) |
| + state.tick(now: 4) |
| XCTAssertFalse(state.visible) |
| } |
| @@ -159,19 +196,19 @@ final class ControlsVisibilityTests: XCTestCase { |
| func testActivityShowsAndRestarts() { |
| var state = V(input: .pointer, now: 0) |
| - state.tick(now: 2) |
| - state.activity(now: 3) // Tab, say |
| + state.tick(now: 3) |
| + state.activity(now: 4) // Tab, say |
| XCTAssertTrue(state.visible) |
| - XCTAssertEqual(state.hideAt, 4.5) |
| + XCTAssertEqual(state.hideAt, 7) |
| } |
| func testThePointerHidesWithTheControlsOnlyOverTheCall() { |
| var state = V(input: .pointer, now: 0) |
| state.pointerMoved(now: 0) |
| XCTAssertFalse(state.hidesPointer, "controls showing") |
| - state.tick(now: 1.5) |
| + state.tick(now: 3) |
| XCTAssertTrue(state.hidesPointer) |
| - state.pointerMoved(now: 2) |
| + state.pointerMoved(now: 4) |
| XCTAssertFalse(state.hidesPointer, "movement brings it back") |
| var touch = V(input: .touch, now: 0) |
| touch.tick(now: 5) |
| @@ -190,12 +227,14 @@ final class ControlsVisibilityTests: XCTestCase { |
| @MainActor func testTheModelHidesOnItsOwnClock() async throws { |
| let model = ControlsVisibilityModel(input: .pointer) |
| XCTAssertTrue(model.visible) |
| - try await Task.sleep(for: .seconds(1.8)) |
| + try await Task.sleep(for: .seconds(2)) |
| + XCTAssertTrue(model.visible, "still up two seconds in") |
| + try await Task.sleep(for: .seconds(1.3)) |
| XCTAssertFalse(model.visible, "hidden by its own timer") |
| model.pointerMoved() |
| XCTAssertTrue(model.visible) |
| model.set(.menuOpen, true) |
| - try await Task.sleep(for: .seconds(1.8)) |
| + try await Task.sleep(for: .seconds(3.3)) |
| XCTAssertTrue(model.visible, "held") |
| } |
| @@ -207,6 +246,92 @@ final class ControlsVisibilityTests: XCTestCase { |
| shown.pointerExited() |
| XCTAssertTrue(shown.visible) |
| } |
| + |
| + /// The traffic lights go and come back with the controls, and are left |
| + /// alone in full screen. |
| + func testTheWindowButtonsFollowTheControls() { |
| + var state = V(input: .pointer, now: 0) |
| + XCTAssertTrue(state.showsWindowButtons(fullScreen: false)) |
| + state.tick(now: 3) |
| + XCTAssertFalse(state.showsWindowButtons(fullScreen: false), "gone with the controls") |
| + XCTAssertTrue(state.showsWindowButtons(fullScreen: true), "full screen's are the system's") |
| + state.pointerMoved(now: 4) |
| + XCTAssertTrue(state.showsWindowButtons(fullScreen: false), "back as soon as the pointer moves") |
| + state.set(.menuOpen, true, now: 4) |
| + state.tick(now: 100) |
| + XCTAssertTrue(state.showsWindowButtons(fullScreen: false), "held up with the controls") |
| + } |
| + |
| + #if os(macOS) |
| + /// The drag strip's double click follows System Settings. |
| + func testTitleBarDoubleClickFollowsTheSetting() { |
| + XCTAssertEqual(TitleBarDoubleClick.from(action: nil, miniaturize: nil), .zoom, "the default") |
| + XCTAssertEqual(TitleBarDoubleClick.from(action: "Maximize", miniaturize: nil), .zoom) |
| + XCTAssertEqual(TitleBarDoubleClick.from(action: "Fill", miniaturize: nil), .zoom) |
| + XCTAssertEqual(TitleBarDoubleClick.from(action: "Minimize", miniaturize: nil), .minimize) |
| + XCTAssertEqual(TitleBarDoubleClick.from(action: "None", miniaturize: true), .none) |
| + XCTAssertEqual(TitleBarDoubleClick.from(action: nil, miniaturize: true), .minimize) |
| + } |
| + #endif |
| + |
| + @MainActor func testAPinnedModelPinsTheWindowButtons() { |
| + XCTAssertFalse( |
| + ControlsVisibilityModel(input: .pointer, pinned: false).showsWindowButtons(fullScreen: false)) |
| + XCTAssertTrue( |
| + ControlsVisibilityModel(input: .pointer, pinned: true).showsWindowButtons(fullScreen: false)) |
| + } |
| +} |
| + |
| +/// WCAG 2.2 §1.4.11: the overlay's symbols and text against what they sit |
| +/// on, at 3:1 or better, over the worst video there is for each — pure |
| +/// white for anything light on the dark surfaces — as well as over black |
| +/// and mid-grey. |
| +final class CallOverlayContrastTests: XCTestCase { |
| + typealias RGB = (r: Double, g: Double, b: Double) |
| + |
| + /// Gamma-encoded sRGB components, which is what the compositor blends |
| + /// and what WCAG's formula starts from. |
| + private func rgb(_ color: Color) -> (RGB, Double) { |
| + let c = color.resolve(in: EnvironmentValues()) |
| + return ((Double(c.red), Double(c.green), Double(c.blue)), Double(c.opacity)) |
| + } |
| + private func over(_ top: Color, _ bottom: RGB) -> RGB { |
| + let (t, a) = rgb(top) |
| + return (t.r * a + bottom.r * (1 - a), t.g * a + bottom.g * (1 - a), t.b * a + bottom.b * (1 - a)) |
| + } |
| + private func luminance(_ c: RGB) -> Double { |
| + func linear(_ v: Double) -> Double { v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) } |
| + return 0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b) |
| + } |
| + private func ratio(_ a: RGB, _ b: RGB) -> Double { |
| + let (x, y) = (luminance(a), luminance(b)) |
| + return (max(x, y) + 0.05) / (min(x, y) + 0.05) |
| + } |
| + |
| + func testEverythingOnTheOverlayIsAtLeastThreeToOne() { |
| + // The formula itself, against WebAIM's figures. |
| + XCTAssertEqual(ratio((1, 1, 1), (0, 0, 0)), 21, accuracy: 0.01) |
| + XCTAssertEqual(ratio((1, 1, 1), (0x77 / 255.0, 0x77 / 255.0, 0x77 / 255.0)), 4.48, accuracy: 0.01) |
| + let white: RGB = (1, 1, 1) |
| + for (name, video) in [("white", white), ("black", (0, 0, 0)), ("grey", (0.2, 0.2, 0.2))] as [(String, RGB)] { |
| + let surface = over(Color.black.opacity(CallOverlay.scrim), video) |
| + let resting = over(CallOverlay.buttonFill, surface) |
| + let checks: [(String, Double, Double)] = [ |
| + ("white on the surface", ratio(white, surface), 7), |
| + ("white symbol on a resting button", ratio(white, resting), 4.5), |
| + ("secondary text", ratio(over(CallOverlay.secondaryText, surface), surface), 4.5), |
| + ("alert text", ratio(rgb(CallOverlay.alertText).0, surface), 4.5), |
| + ("an on button against the bar", ratio(rgb(CallOverlay.onFill).0, surface), 3), |
| + ("an on button's symbol", ratio(rgb(CallOverlay.onSymbol).0, rgb(CallOverlay.onFill).0), 4.5), |
| + ("white on amber (off)", ratio(white, rgb(CallOverlay.offFill).0), 4.5), |
| + ("white on Leave's red", ratio(white, rgb(CallOverlay.leaveFill).0), 4.5), |
| + ("the unread badge", ratio(white, rgb(CallOverlay.badgeFill).0), 4.5), |
| + ] |
| + for (what, measured, least) in checks { |
| + XCTAssertGreaterThanOrEqual(measured, least, "\(what) over \(name) video: \(measured)") |
| + } |
| + } |
| + } |
| } |
| final class PiPLayoutTests: XCTestCase { |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index 0a8521c..4d50883 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -1,4 +1,4 @@ |
| -import CallsKit |
| +@_spi(Fixtures) import CallsKit |
| import SwiftUI |
| import LiveKitWebRTC |
| @@ -124,6 +124,7 @@ struct PhoneCall: View { |
| @AppStorage("CallsMicBesideMenu") var micBesideMenu = false |
| @Environment(\.accessibilityReduceMotion) var reduceMotion |
| @Environment(\.accessibilityVoiceOverEnabled) var voiceOver |
| + @Environment(\.fixtureBackdrop) var fixtureBackdrop |
| private let style: CallLayoutStyle? |
| init( |
diff --git a/native/ios/Sources/PhoneCallFixture.swift b/native/ios/Sources/PhoneCallFixture.swift| index 1bcffdc..b38a236 100644 |
| --- a/native/ios/Sources/PhoneCallFixture.swift |
| +++ b/native/ios/Sources/PhoneCallFixture.swift |
| @@ -7,7 +7,9 @@ |
| /// with `-CallsCallFixture focus` (or grid, screen, notices), and |
| /// optionally `-CallsFixtureControls shown|hidden` to pin the controls, |
| /// `-CallsFixtureMore YES` to open the controls menu, `-CallsFixtureDark |
| - /// YES` and `-CallsFixtureClassic YES`. |
| + /// YES`, `-CallsFixtureClassic YES`, `-CallsFixtureBackdrop black|white|busy` |
| + /// for a frame in place of the stage, and `-CallsFixtureSelf |
| + /// muted|cameraOff|sharing` for your own state. |
| struct PhoneCallFixture: View { |
| static var requested: CallFixture? { |
| UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:)) |
| @@ -28,11 +30,16 @@ |
| grid: fixture == .grid) |
| } |
| .tint(CallsAppearance.accent) |
| + .environment( |
| + \.fixtureBackdrop, |
| + defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:))) |
| .preferredColorScheme(defaults.bool(forKey: "CallsFixtureDark") ? .dark : .light) |
| .onAppear { |
| guard !applied else { return } |
| applied = true |
| fixture.apply(to: call) |
| + UserDefaults.standard.string(forKey: "CallsFixtureSelf") |
| + .flatMap(FixtureSelf.init(rawValue:))?.apply(to: call) |
| } |
| } |
| } |
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift| index d904ca1..a494ca0 100644 |
| --- a/native/ios/Sources/PhoneFullScreenCall.swift |
| +++ b/native/ios/Sources/PhoneFullScreenCall.swift |
| @@ -1,4 +1,4 @@ |
| -import CallsKit |
| +@_spi(Fixtures) import CallsKit |
| import SwiftUI |
| /// What the controls sheet was closed in order to open. |
| @@ -6,7 +6,7 @@ enum MoreAction { case devices, chat } |
| /// The full-screen call: the picture under the status bar and the home |
| /// indicator, both of which go with the controls. A tap shows the |
| -/// controls and another hides them; they go on their own 2.5 s after the |
| +/// controls and another hides them; they go on their own 3 s after the |
| /// last touch (see ControlsVisibility for why that long, and what keeps |
| /// them up). The bar is a controls menu on the left and End call on the |
| /// right; everything else is in the menu. |
| @@ -78,8 +78,7 @@ extension PhoneCall { |
| } |
| private var fade: Animation { |
| - .easeInOut( |
| - duration: ControlsVisibility.fadeDuration(showing: chrome.visible, reduceMotion: reduceMotion)) |
| + ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion) |
| } |
| private func updateHolds() { |
| @@ -93,7 +92,9 @@ extension PhoneCall { |
| let lift = EdgeInsets( |
| top: 0, leading: safe.leading, bottom: safe.bottom + (chrome.visible ? Self.barHeight : 0), |
| trailing: 0) |
| - if grid { |
| + if let fixtureBackdrop { |
| + fixtureBackdrop.view.accessibilityHidden(true) |
| + } else if grid { |
| grid(in: size, safe: safe) |
| } else { |
| switch focused { |
| @@ -231,16 +232,16 @@ extension PhoneCall { |
| VStack(spacing: 8) { |
| // The title goes with the controls; the notices under it do not. |
| VStack(alignment: .leading, spacing: 2) { |
| - Text(call.title).font(.headline).lineLimit(2) |
| - Text(call.status).font(.caption).foregroundStyle(.secondary) |
| + Text(call.title).font(.headline).foregroundStyle(.white).lineLimit(2) |
| + Text(call.status).font(.caption).foregroundStyle(CallOverlay.secondaryText) |
| .accessibilityAddTraits(.updatesFrequently) |
| ForEach(notices.filter { !$0.important }) { notice in |
| - Text(notice.text).font(.caption).foregroundStyle(.secondary) |
| + Text(notice.text).font(.caption).foregroundStyle(CallOverlay.secondaryText) |
| .fixedSize(horizontal: false, vertical: true) |
| } |
| } |
| .padding(.horizontal, 12).padding(.vertical, 8) |
| - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) |
| + .callOverlaySurface(RoundedRectangle(cornerRadius: 14, style: .continuous)) |
| .frame(maxWidth: .infinity, alignment: .leading) |
| .opacity(chrome.visible ? 1 : 0) |
| .allowsHitTesting(chrome.visible) |
| @@ -267,9 +268,10 @@ extension PhoneCall { |
| HStack(spacing: 10) { |
| switch notice.kind { |
| case .sharing: |
| - Label(notice.text, systemImage: "rectangle.on.rectangle").foregroundStyle(.orange).bold() |
| + Label(notice.text, systemImage: "rectangle.on.rectangle") |
| + .foregroundStyle(CallOverlay.alertText).bold() |
| case .reconnecting, .waitingToJoin: |
| - ProgressView() |
| + ProgressView().tint(.white) |
| Text(notice.text) |
| case .full: |
| Text(notice.text) |
| @@ -297,11 +299,11 @@ extension PhoneCall { |
| Label("More controls", systemImage: "ellipsis") |
| .labelStyle(.iconOnly).rotationEffect(.degrees(90)) |
| } |
| - .buttonStyle(RoundCallButtonStyle(tint: nil)) |
| + .buttonStyle(RoundCallButtonStyle(.normal)) |
| .overlay(alignment: .topTrailing) { |
| if unread > 0 { |
| Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) |
| - .padding(5).background(.purple, in: Capsule()).accessibilityHidden(true) |
| + .padding(5).background(CallOverlay.badgeFill, in: Capsule()).accessibilityHidden(true) |
| } |
| } |
| .accessibilityLabel(unread > 0 ? "More controls, \(unread) new chat messages" : "More controls") |
| @@ -312,13 +314,13 @@ extension PhoneCall { |
| systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| ) { call.mute() } |
| .labelStyle(.iconOnly) |
| - .buttonStyle(RoundCallButtonStyle(tint: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : nil)) |
| + .buttonStyle(RoundCallButtonStyle(call.muted ? .off : .normal)) |
| .accessibilityIdentifier("mute") |
| } |
| Spacer(minLength: 0) |
| Button("End call", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| .labelStyle(.iconOnly) |
| - .buttonStyle(RoundCallButtonStyle(tint: .red)) |
| + .buttonStyle(RoundCallButtonStyle(.leave)) |
| .accessibilityIdentifier("leave") |
| } |
| .padding(.leading, safe.leading + 20).padding(.trailing, safe.trailing + 20) |
| @@ -333,18 +335,22 @@ extension PhoneCall { |
| } |
| } |
| -/// A round call button: material with a white symbol, or filled with |
| -/// `tint` (End call's red, a muted microphone's amber). |
| +/// A round call button over the picture: the overlay's dark surface with |
| +/// a white symbol, or filled for End call's red and a muted microphone's |
| +/// amber (see CallOverlay). |
| struct RoundCallButtonStyle: ButtonStyle { |
| - let tint: Color? |
| + let tone: CallOverlayButtonStyle.Tone |
| + init(_ tone: CallOverlayButtonStyle.Tone) { self.tone = tone } |
| func makeBody(configuration: Configuration) -> some View { |
| - configuration.label.font(.title2.weight(.semibold)).foregroundStyle(.white) |
| + configuration.label.font(.title2.weight(.semibold)) |
| + .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white) |
| .frame(width: PhoneCall.button, height: PhoneCall.button) |
| .background { |
| - if let tint { |
| - Circle().fill(tint) |
| - } else { |
| - Circle().fill(.ultraThinMaterial).environment(\.colorScheme, .dark) |
| + switch tone { |
| + case .normal: Color.clear.callOverlaySurface(Circle()) |
| + case .on: Circle().fill(CallOverlay.onFill) |
| + case .off: Circle().fill(CallOverlay.offFill) |
| + case .leave: Circle().fill(CallOverlay.leaveFill) |
| } |
| } |
| .opacity(configuration.isPressed ? 0.7 : 1) |
| @@ -352,12 +358,15 @@ struct RoundCallButtonStyle: ButtonStyle { |
| } |
| } |
| +/// A notice over the picture: the overlay's dark surface, white text, and |
| +/// dark-appearance controls whatever the system's. |
| struct PhoneNoticeSurface: ViewModifier { |
| func body(content: Content) -> some View { |
| - content.font(.callout) |
| + content.font(.callout).foregroundStyle(.white) |
| + .environment(\.colorScheme, .dark) |
| .padding(.horizontal, 14).padding(.vertical, 8) |
| - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) |
| - .shadow(color: .black.opacity(0.2), radius: 8, y: 2) |
| + .callOverlaySurface(RoundedRectangle(cornerRadius: 16, style: .continuous)) |
| + .shadow(color: .black.opacity(0.25), radius: 8, y: 2) |
| } |
| } |
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift| index 8fde901..07b7ce2 100644 |
| --- a/native/ios/UITests/CallsPhoneUITests.swift |
| +++ b/native/ios/UITests/CallsPhoneUITests.swift |
| @@ -158,7 +158,7 @@ final class CallsPhoneUITests: XCTestCase { |
| } |
| /// Paul's layout: the menu on the left, End call on the right, fading |
| - /// 2.5 s after the last touch, back with a tap, and every other control |
| + /// 3 s after the last touch, back with a tap, and every other control |
| /// in the menu. |
| @MainActor func testFullScreenCallControls() throws { |
| XCUIDevice.shared.orientation = .portrait |
| @@ -243,6 +243,35 @@ final class CallsPhoneUITests: XCTestCase { |
| } |
| } |
| + /// The overlay over black, white and a busy frame, in each of your own |
| + /// states, light and dark: it should look the same in both. The |
| + /// microphone sits beside the menu here so its muted state shows. |
| + @MainActor func testOverlayContrastSnapshots() throws { |
| + XCUIDevice.shared.orientation = .portrait |
| + for dark in [false, true] { |
| + for backdrop in ["black", "white", "busy"] { |
| + for me in ["normal", "muted", "cameraOff", "sharing"] { |
| + let app = launchCall( |
| + "focus", |
| + [ |
| + "-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", backdrop, |
| + "-CallsFixtureSelf", me, "-CallsMicBesideMenu", "YES", |
| + ], dark: dark) |
| + XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15)) |
| + XCTAssertTrue(app.buttons["mute"].exists, "beside the menu") |
| + if me == "muted" { XCTAssertEqual(app.buttons["mute"].label, "Unmute microphone") } |
| + Thread.sleep(forTimeInterval: 1) |
| + try snapshot(app, "contrast-\(backdrop)-\(me)-\(dark ? "dark" : "light")") |
| + } |
| + } |
| + let notices = launchCall( |
| + "notices", ["-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", "white"], dark: dark) |
| + XCTAssertTrue(notices.buttons["Let in"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1) |
| + try snapshot(notices, "contrast-white-notices-\(dark ? "dark" : "light")") |
| + } |
| + } |
| + |
| /// Landscape keeps both bar buttons on screen, clear of the Dynamic |
| /// Island's side. |
| @MainActor func testLandscapeKeepsTheBarOnScreen() throws { |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 9f6ecec..2e12969 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.3.4' |
| + MARKETING_VERSION: '0.3.5' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |