Merge branch 'fix/bar-icon-alignment' into main
Control glyphs centred by their ink on Mac, iPhone and web; filmstrip labels and placeholders kept clear of each other. Calls 0.4.1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+903
−61
internal/calls/static/calls.css+55 −0internal/callstest/browser_test.go+1 −0native/apple/Sources/CallsKit/CallActionStyle.swift+2 −0native/apple/Sources/CallsKit/CallGlyph.swift+184 −0native/apple/Sources/CallsKit/MacCallView.swift+7 −7native/apple/Sources/CallsKit/MacVideoTile.swift+13 −10native/apple/Sources/CallsKit/MeetingShareButton.swift+1 −1native/apple/Sources/CallsKit/MiniWindow.swift+3 −3native/apple/Sources/CallsKit/MutedBadge.swift+84 −10native/apple/Tests/CallsKitTests/CallGlyphTests.swift+236 −0native/ios/Sources/CallsPhone.swift+18 −16native/ios/Sources/PhoneFullScreenCall.swift+21 −12native/ios/UITests/CallsPhoneUITests.swift+105 −0native/ios/project.yml+1 −1test/browser/call.mjs+172 −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.426s 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 8.431s ? 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.443s 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 24.089s
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css| index 9ae6ef5..d8c9019 100644 |
| --- a/internal/calls/static/calls.css |
| +++ b/internal/calls/static/calls.css |
| @@ -652,3 +652,58 @@ |
| @media (prefers-reduced-motion: no-preference) { |
| .calls-tile-lifted { scale: 1.04; } |
| } |
| + |
| +/* 0.4.1: the bar's glyphs sit on the bar's centre line by their ink, |
| + not by their boxes. A Lucide glyph is drawn to its 24-unit square, |
| + and where its weight falls inside it is its own: the microphone's |
| + capsule and arc are its weight, and they are above its stem, so its |
| + ink sat 0.9 px low. Each glyph that needs it gets one line here, |
| + measured by the browser gate's glyphs scenario, which holds every |
| + glyph to within half a pixel of the centre line — the same rule, the |
| + same table and the same test as the native CallGlyph. */ |
| +#controls.calls-controls button .icon { |
| + flex: none; |
| + translate: 0 var(--calls-glyph-dy, 0px); |
| +} |
| +#controls.calls-controls #mute { --calls-glyph-dy: -0.9px; } |
| + |
| +/* A filmstrip tile, or any small one (0.4.1). The camera-off cover's |
| + symbol and words are centred in the space above the caption, never |
| + under it, and a tile too small for the words keeps the symbol alone, |
| + then a smaller one. The caption stays one line: the name gives up its |
| + end to an ellipsis, the muted mark keeps its slashed microphone and |
| + drops the word (still read out: it is only visually hidden), and |
| + there is room past the caption's end for the speaking glow. */ |
| +.calls-tile { container-type: inline-size; } |
| +.calls-tile-off { padding-block-end: 2.75rem; } |
| +.calls-tile-name [rst-badge] { color: #fff; border-color: rgb(255 255 255 / 0.5); } |
| +@container (max-width: 20rem) { |
| + .calls-tile-name { |
| + flex-wrap: nowrap; |
| + box-sizing: border-box; |
| + max-inline-size: calc(100% - 2 * var(--rst-sp-2) - 6px); |
| + } |
| + .calls-tile-name > span:first-child { flex: 0 1 auto; min-inline-size: 0; } |
| + .calls-tile-name > :not(:first-child) { flex: none; } |
| + .calls-tile-muted > :not(svg) { |
| + position: absolute; |
| + inline-size: 1px; |
| + block-size: 1px; |
| + overflow: hidden; |
| + clip-path: inset(50%); |
| + white-space: nowrap; |
| + } |
| +} |
| +@container (max-width: 15rem) { |
| + .calls-tile-off > :not(svg) { |
| + position: absolute; |
| + inline-size: 1px; |
| + block-size: 1px; |
| + overflow: hidden; |
| + clip-path: inset(50%); |
| + white-space: nowrap; |
| + } |
| +} |
| +@container (max-width: 9rem) { |
| + .calls-tile-off .icon { inline-size: 1.25rem; block-size: 1.25rem; } |
| +} |
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go| index bb259d9..26e16c3 100644 |
| --- a/internal/callstest/browser_test.go |
| +++ b/internal/callstest/browser_test.go |
| @@ -82,6 +82,7 @@ const ( |
| var scenarios = []string{ |
| "guestdirect", |
| "layout", |
| + "glyphs", |
| "devices", |
| "audiotest", |
| "call", |
diff --git a/native/apple/Sources/CallsKit/CallActionStyle.swift b/native/apple/Sources/CallsKit/CallActionStyle.swift| index 8653a85..bb1dbcf 100644 |
| --- a/native/apple/Sources/CallsKit/CallActionStyle.swift |
| +++ b/native/apple/Sources/CallsKit/CallActionStyle.swift |
| @@ -28,6 +28,7 @@ public struct CallActionStyle: ButtonStyle { |
| pressed: configuration.isPressed, enabled: isEnabled) |
| } else { |
| configuration.label.labelStyle(.iconOnly).font(.title3) |
| + .modifier(CallGlyphSizing(.action)) |
| .frame(width: 44, height: 44) |
| .foregroundStyle(emphasized ? .white : color) |
| .background(emphasized ? color : color.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) |
| @@ -186,6 +187,7 @@ private struct OverlayButtonBody<Label: View>: View { |
| let width = tone == .leave && !round ? size * 1.4 : size |
| label.labelStyle(.iconOnly) |
| .font(round ? .title2.weight(.semibold) : .title3.weight(.medium)) |
| + .modifier(CallGlyphSizing(round ? .round : .bar)) |
| .frame(width: width, height: size) |
| .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white) |
| .background(fill, in: Capsule()) |
diff --git a/native/apple/Sources/CallsKit/CallGlyph.swift b/native/apple/Sources/CallsKit/CallGlyph.swift| new file mode 100644 |
| index 0000000..d061e6b |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/CallGlyph.swift |
| @@ -0,0 +1,184 @@ |
| +import SwiftUI |
| + |
| +/// A call control's symbol, centred on its button by its ink (0.4.1). |
| +/// |
| +/// SF Symbols are laid out to sit on a line of text, not to be centred in |
| +/// a circle: each is placed by the font's baseline and cap height, and |
| +/// how its weight is spread inside that box is its own business. In a row |
| +/// of borderless buttons that shows. Paul measured the 0.4.0 bar: the |
| +/// share symbol's ink sat 1.25 pt below the others', because its weight |
| +/// is the tray at the bottom and the arrow above it is a thin line. |
| +/// |
| +/// So every symbol a call's controls draw goes through here: set at one |
| +/// point size and weight (`CallGlyphMetrics`, from the button's style), |
| +/// centred in one square the same for every symbol, and moved by its |
| +/// entry in `opticalOffsets`, so that its ink's centre of mass lies on |
| +/// the square's centre line. `CallGlyphTests` renders each symbol at |
| +/// every size the controls use and holds that to within half a point. |
| +public struct CallGlyph: View { |
| + public let symbol: String |
| + /// The ellipsis stood on end, for the iPhone's ⋯ button. The optical |
| + /// offset is along the symbol's own vertical, so a quarter turn makes |
| + /// it a sideways one; the ellipsis is symmetric, so there is none. |
| + public var rotation: Angle |
| + /// Off only to measure where SF Symbols alone would put the ink; |
| + /// `trial` measures a correction (in ems) before it goes in the table. |
| + var corrected = true |
| + var trial: CGFloat? = nil |
| + @Environment(\.callGlyphMetrics) private var metrics |
| + @Environment(\.displayScale) private var displayScale |
| + |
| + public init(_ symbol: String, rotation: Angle = .zero) { |
| + self.symbol = symbol |
| + self.rotation = rotation |
| + } |
| + |
| + public var body: some View { |
| + PixelSquare( |
| + side: metrics.box, |
| + lift: (trial ?? (corrected ? Self.opticalOffset(symbol) : 0)) * metrics.points, |
| + scale: displayScale |
| + ) { |
| + Image(systemName: symbol) |
| + .font(.system(size: metrics.points, weight: metrics.weight)) |
| + .imageScale(.medium) |
| + } |
| + .rotationEffect(rotation) |
| + } |
| + |
| + /// How far each symbol moves to put its ink's centroid on the centre |
| + /// line, in ems (a fraction of the point size, so it holds at every |
| + /// size); negative is up. Tuned by `CallGlyphTests.testTuneTheTable` |
| + /// (run with CALLS_GLYPH_TUNE=1), which prints a fresh table; a symbol |
| + /// added to the controls needs an entry, or it is drawn where SF |
| + /// Symbols puts it and nothing measures it. |
| + public static let opticalOffsets: [String: CGFloat] = [ |
| + "mic.fill": -0.016, |
| + "mic.slash.fill": -0.016, |
| + "video.fill": -0.008, |
| + "video.slash.fill": -0.008, |
| + "rectangle.on.rectangle": 0.016, |
| + "rectangle.on.rectangle.slash": 0.014, |
| + "slider.horizontal.3": 0.000, |
| + "rectangle.bottomthird.inset.filled": -0.070, |
| + "square.grid.2x2": -0.008, |
| + "square.and.arrow.up": -0.110, |
| + "bubble.left.and.bubble.right.fill": 0.074, |
| + "phone.down.fill": 0.014, |
| + "ellipsis": 0.000, |
| + "arrow.triangle.2.circlepath.camera": -0.042, |
| + "waveform": -0.008, |
| + "arrow.down.right.and.arrow.up.left": -0.008, |
| + ] |
| + |
| + public static func opticalOffset(_ symbol: String) -> CGFloat { |
| + opticalOffsets[symbol] ?? 0 |
| + } |
| +} |
| + |
| +/// The square a symbol is centred in, with the symbol placed on whole |
| +/// pixels by this layout rather than by the renderer. A symbol's box is |
| +/// a fraction of a point tall, so centring it leaves it half a pixel |
| +/// off the grid, and which way that half pixel then rounds is the |
| +/// renderer's choice: a window rounded it one way and an off-screen |
| +/// render the other, a pixel apart. Rounding here makes the two agree, |
| +/// and makes what the tests measure what the screen shows. |
| +struct PixelSquare: Layout { |
| + let side: CGFloat |
| + let lift: CGFloat |
| + let scale: CGFloat |
| + |
| + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { |
| + CGSize(width: side, height: side) |
| + } |
| + |
| + func placeSubviews( |
| + in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () |
| + ) { |
| + let scale = max(scale, 1) |
| + func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale } |
| + for subview in subviews { |
| + let size = subview.sizeThatFits(.unspecified) |
| + subview.place( |
| + at: CGPoint( |
| + x: bounds.minX + snap((side - size.width) / 2), |
| + y: bounds.minY + snap((side - size.height) / 2 + lift)), |
| + proposal: ProposedViewSize(size)) |
| + } |
| + } |
| +} |
| + |
| +/// The size a button's style sets its symbol at: one point size and |
| +/// weight for every symbol in a row, and the square they are centred in. |
| +public struct CallGlyphMetrics: Equatable, Sendable { |
| + public var points: CGFloat |
| + public var weight: Font.Weight |
| + public var box: CGFloat |
| + |
| + public init(points: CGFloat, weight: Font.Weight) { |
| + self.points = points |
| + self.weight = weight |
| + // Wide enough for the widest (the two chat bubbles), and the same |
| + // for all of them; an even number of points, so that centred in a |
| + // button of 44 or 56 it starts on a whole point. |
| + self.box = (points * 0.95).rounded() * 2 |
| + } |
| + |
| + // The text styles the buttons used before 0.4.1, as point sizes, so no |
| + // symbol changed size: macOS's title3 and title2 are 15 and 17 pt, |
| + // iOS's 20 and 22 pt at the default text size. |
| + #if os(macOS) |
| + static let title3: CGFloat = 15 |
| + static let title2: CGFloat = 17 |
| + #else |
| + static let title3: CGFloat = 20 |
| + static let title2: CGFloat = 22 |
| + #endif |
| + |
| + /// The full-window bar's buttons. |
| + public static let bar = CallGlyphMetrics(points: title3, weight: .medium) |
| + /// A round button: the iPhone's ⋯, microphone and End call. |
| + public static let round = CallGlyphMetrics(points: title2, weight: .semibold) |
| + /// The classic row, the mini window, and the iPhone's controls sheet. |
| + public static let action = CallGlyphMetrics(points: title3, weight: .regular) |
| + |
| + /// `self`, scaled for the person's text size. |
| + func scaled(_ factor: CGFloat) -> CallGlyphMetrics { |
| + CallGlyphMetrics(points: (points * factor).rounded(), weight: weight) |
| + } |
| +} |
| + |
| +/// Sets the `CallGlyph`s inside at `metrics`, grown or shrunk with the |
| +/// person's text size as the text style the buttons used to set did. |
| +public struct CallGlyphSizing: ViewModifier { |
| + let metrics: CallGlyphMetrics |
| + @ScaledMetric(relativeTo: .title3) private var textScale: CGFloat = 1 |
| + public init(_ metrics: CallGlyphMetrics) { self.metrics = metrics } |
| + public func body(content: Content) -> some View { |
| + content.environment(\.callGlyphMetrics, textScale == 1 ? metrics : metrics.scaled(textScale)) |
| + } |
| +} |
| + |
| +private struct CallGlyphMetricsKey: EnvironmentKey { |
| + static let defaultValue = CallGlyphMetrics.action |
| +} |
| + |
| +extension EnvironmentValues { |
| + /// See `CallGlyphMetrics`. Set by the call buttons' styles. |
| + public var callGlyphMetrics: CallGlyphMetrics { |
| + get { self[CallGlyphMetricsKey.self] } |
| + set { self[CallGlyphMetricsKey.self] = newValue } |
| + } |
| +} |
| + |
| +extension Button where Label == SwiftUI.Label<Text, CallGlyph> { |
| + /// A call control: its title for VoiceOver and the tooltip, and |
| + /// `glyph` drawn as a `CallGlyph`. |
| + public init( |
| + _ title: String, glyph: String, role: ButtonRole? = nil, action: @escaping () -> Void |
| + ) { |
| + self.init(role: role, action: action) { |
| + SwiftUI.Label { Text(title) } icon: { CallGlyph(glyph) } |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index 4e629bc..6769d5f 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -344,7 +344,7 @@ |
| HStack(spacing: fullWindow ? 10 : 12) { |
| Button( |
| call.muted ? "Unmute microphone" : "Mute microphone", |
| - systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| + glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| ) { call.mute() } |
| .buttonStyle( |
| CallActionStyle( |
| @@ -356,7 +356,7 @@ |
| .focused($focusedControl, equals: .mute) |
| Button( |
| call.cameraOff ? "Turn camera on" : "Turn camera off", |
| - systemImage: call.cameraOff ? "video.slash.fill" : "video.fill" |
| + glyph: call.cameraOff ? "video.slash.fill" : "video.fill" |
| ) { call.toggleCamera() } |
| .buttonStyle( |
| CallActionStyle( |
| @@ -368,7 +368,7 @@ |
| .focused($focusedControl, equals: .camera) |
| Button( |
| call.sharingScreen ? "Stop sharing your screen" : "Share your screen", |
| - systemImage: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" |
| + glyph: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" |
| ) { |
| if call.sharingScreen { |
| call.stopSharingScreen() |
| @@ -384,7 +384,7 @@ |
| .disabled(call.state?.phase != "admitted") |
| .accessibilityIdentifier("shareScreen") |
| .focused($focusedControl, equals: .share) |
| - Button("Devices", systemImage: "slider.horizontal.3") { |
| + Button("Devices", glyph: "slider.horizontal.3") { |
| withAnimation(panelAnimation) { showingDevices.toggle() } |
| } |
| .buttonStyle( |
| @@ -402,7 +402,7 @@ |
| .help("Share meeting link") |
| .focused($focusedControl, equals: .link) |
| Spacer(minLength: fullWindow ? 24 : nil) |
| - Button(chatTitle, systemImage: "bubble.left.and.bubble.right.fill") { |
| + Button(chatTitle, glyph: "bubble.left.and.bubble.right.fill") { |
| if chatWindow.isOpen { chatWindow.close() } |
| withAnimation(panelAnimation) { showingChat.toggle() } |
| }.buttonStyle( |
| @@ -426,7 +426,7 @@ |
| chatWindow.open(call: call) |
| } |
| } |
| - Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| + Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave) |
| .buttonStyle(CallActionStyle(color: .red, emphasized: true, overlay: tone(fullWindow, .leave))) |
| .help("Leave meeting") |
| .accessibilityIdentifier("leave") |
| @@ -439,7 +439,7 @@ |
| /// the small popover it opens. |
| private var layoutMenu: some View { |
| Button( |
| - "Video layout", systemImage: arrangement.layout.symbol |
| + "Video layout", glyph: arrangement.layout.symbol |
| ) { showingLayout.toggle() } |
| .buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout, overlay: showingLayout ? .on : .normal)) |
| .help("Video layout: Speaker or Grid, and who is pinned to the stage") |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index f6f45e1..725290b 100644 |
| --- a/native/apple/Sources/CallsKit/MacVideoTile.swift |
| +++ b/native/apple/Sources/CallsKit/MacVideoTile.swift |
| @@ -40,6 +40,10 @@ |
| /// hears them as the tile's value. |
| var iconOnly = false |
| + /// The height of the name label and what is over it, lift included, |
| + /// which the placeholder stays above. |
| + @State private var labelBlock: CGFloat = 0 |
| + |
| var body: some View { |
| TileAudioReader(audio) { info in tile(info) } |
| } |
| @@ -50,16 +54,11 @@ |
| if let track, !cameraOff, placeholder == nil { |
| MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| - VStack(spacing: 12) { |
| - Image( |
| - systemName: cameraOff |
| - ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle" |
| - ) |
| - .font(iconOnly ? .title2 : .largeTitle) |
| - if !iconOnly { |
| - Text(waiting).font(.callout).multilineTextAlignment(.center).padding(.horizontal, 12) |
| - } |
| - }.foregroundStyle(.white.opacity(0.8)) |
| + TilePlaceholder( |
| + symbol: cameraOff |
| + ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle", |
| + words: iconOnly ? nil : waiting, iconFont: iconOnly ? .title2 : .largeTitle, |
| + reserve: labelBlock) |
| } |
| } |
| .overlay(alignment: .bottomLeading) { |
| @@ -81,6 +80,10 @@ |
| } |
| } |
| .padding(compact ? 8 : 10).padding(.bottom, compact ? labelLift : 0) |
| + // Room past the label's end for the speaking glow, when a long |
| + // name has it truncating. |
| + .padding(.trailing, 6) |
| + .tileLabelBlock($labelBlock) |
| } |
| .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(10) } } |
| .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) |
diff --git a/native/apple/Sources/CallsKit/MeetingShareButton.swift b/native/apple/Sources/CallsKit/MeetingShareButton.swift| index 2038ef0..cd317f4 100644 |
| --- a/native/apple/Sources/CallsKit/MeetingShareButton.swift |
| +++ b/native/apple/Sources/CallsKit/MeetingShareButton.swift |
| @@ -14,7 +14,7 @@ import SwiftUI |
| public var body: some View { |
| if call.standing == "member" { |
| - Button("Share", systemImage: "square.and.arrow.up") { showing = true } |
| + Button("Share", glyph: "square.and.arrow.up") { showing = true } |
| .accessibilityLabel("Share meeting link") |
| .sheet(isPresented: $showing) { |
| MeetingInvitationView(call: call, close: { showing = false }) |
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift| index 8f82d84..9b819b0 100644 |
| --- a/native/apple/Sources/CallsKit/MiniWindow.swift |
| +++ b/native/apple/Sources/CallsKit/MiniWindow.swift |
| @@ -456,14 +456,14 @@ |
| } |
| Spacer() |
| if showingControls { |
| - Button("Collapse to a tab", systemImage: "arrow.down.right.and.arrow.up.left") { |
| + Button("Collapse to a tab", glyph: "arrow.down.right.and.arrow.up.left") { |
| actions.collapse() |
| } |
| .buttonStyle(CallActionStyle(color: Color(white: 0.3), emphasized: true)) |
| .help("Collapse to a tab at the edge of the screen") |
| Button( |
| call.muted ? "Unmute microphone" : "Mute microphone", |
| - systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| + glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| ) { call.mute() } |
| .buttonStyle( |
| CallActionStyle( |
| @@ -473,7 +473,7 @@ |
| ) |
| .help(call.muted ? "Unmute microphone" : "Mute microphone") |
| Button( |
| - "Leave meeting", systemImage: "phone.down.fill", role: .destructive, |
| + "Leave meeting", glyph: "phone.down.fill", role: .destructive, |
| action: actions.leave |
| ) |
| .buttonStyle(CallActionStyle(color: .red, emphasized: true)) |
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift| index e734fa9..cddb9ea 100644 |
| --- a/native/apple/Sources/CallsKit/MutedBadge.swift |
| +++ b/native/apple/Sources/CallsKit/MutedBadge.swift |
| @@ -27,6 +27,12 @@ public func tileAccessibilityLabel(_ label: String, muted: Bool) -> String { |
| /// second badge, so a tile keeps one quiet |
| /// label in its corner that can stay while the controls come and go. |
| /// Hidden from assistive technology, as the tile's own label says both. |
| +/// |
| +/// A narrow tile (a filmstrip thumbnail, most of all on an iPhone held |
| +/// upright) has no room for "Ana García | Muted": there the word goes and |
| +/// the slashed microphone stays, and the name gives up its end to an |
| +/// ellipsis rather than running off the tile (0.4.1). The tile's own |
| +/// accessibility label still says "muted". |
| public struct TileNameLabel: View { |
| let label: String |
| let muted: Bool |
| @@ -35,22 +41,90 @@ public struct TileNameLabel: View { |
| self.muted = muted |
| } |
| public var body: some View { |
| - HStack(spacing: 5) { |
| - Text(label).lineLimit(1) |
| - if muted { |
| - Label("Muted", systemImage: "mic.slash.fill").labelStyle(.titleAndIcon) |
| - .padding(.leading, 5) |
| - .overlay(alignment: .leading) { |
| - Rectangle().fill(.white.opacity(0.5)).frame(width: 1, height: 10) |
| - } |
| - } |
| + ViewThatFits(in: .horizontal) { |
| + row(word: true).fixedSize() |
| + row(word: false).fixedSize(horizontal: false, vertical: true) |
| } |
| .font(.caption.weight(.semibold)).foregroundStyle(.white) |
| - .fixedSize() |
| .padding(.horizontal, 7).padding(.vertical, 3) |
| // The overlay's glass, as the controls have: white on it is 6.2:1 |
| // even over a white wall. |
| .callOverlaySurface(Capsule()) |
| .accessibilityHidden(true) |
| } |
| + |
| + private func row(word: Bool) -> some View { |
| + HStack(spacing: 5) { |
| + Text(label).lineLimit(1).truncationMode(.tail) |
| + if muted { |
| + Group { |
| + if word { |
| + Label("Muted", systemImage: "mic.slash.fill").labelStyle(.titleAndIcon) |
| + } else { |
| + Image(systemName: "mic.slash.fill") |
| + } |
| + } |
| + .padding(.leading, 5) |
| + .overlay(alignment: .leading) { |
| + Rectangle().fill(.white.opacity(0.5)).frame(width: 1, height: 10) |
| + } |
| + .fixedSize() |
| + .layoutPriority(1) |
| + } |
| + } |
| + } |
| +} |
| + |
| +/// What a tile shows where its picture is not: a symbol, and the words |
| +/// for it when there is room (0.4.1). Centred in the space the tile has |
| +/// above its name label (`reserve`), never under it, and on a tile too |
| +/// short for the words the symbol stands alone, then smaller. The tile's |
| +/// accessibility value carries the words either way. |
| +public struct TilePlaceholder: View { |
| + let symbol: String |
| + let words: String? |
| + let iconFont: Font |
| + let wordsFont: Font |
| + let spacing: CGFloat |
| + let reserve: CGFloat |
| + |
| + public init( |
| + symbol: String, words: String?, iconFont: Font = .largeTitle, wordsFont: Font = .callout, |
| + spacing: CGFloat = 12, reserve: CGFloat |
| + ) { |
| + self.symbol = symbol |
| + self.words = words |
| + self.iconFont = iconFont |
| + self.wordsFont = wordsFont |
| + self.spacing = spacing |
| + self.reserve = reserve |
| + } |
| + |
| + public var body: some View { |
| + ViewThatFits(in: .vertical) { |
| + if let words { |
| + VStack(spacing: spacing) { |
| + Image(systemName: symbol).font(iconFont) |
| + Text(words).font(wordsFont).multilineTextAlignment(.center).lineLimit(2) |
| + .padding(.horizontal, 12) |
| + } |
| + } |
| + Image(systemName: symbol).font(iconFont) |
| + Image(systemName: symbol).font(.body) |
| + } |
| + .foregroundStyle(.white.opacity(0.8)) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + // A little air between the placeholder and the label as well. |
| + .padding(.bottom, reserve > 0 ? reserve + 4 : 0) |
| + .accessibilityHidden(true) |
| + } |
| +} |
| + |
| +/// A tile's name label block, measured, so its placeholder can keep |
| +/// clear of it (see `TilePlaceholder`). |
| +extension View { |
| + /// Reports this view's height as the tile's label block. |
| + public func tileLabelBlock(_ height: Binding<CGFloat>) -> some View { |
| + onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height.wrappedValue = $0 } |
| + } |
| } |
diff --git a/native/apple/Tests/CallsKitTests/CallGlyphTests.swift b/native/apple/Tests/CallsKitTests/CallGlyphTests.swift| new file mode 100644 |
| index 0000000..c7195a1 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/CallGlyphTests.swift |
| @@ -0,0 +1,236 @@ |
| +import AppKit |
| +import SwiftUI |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// Paul, on the 0.4.0 bar: "Are these vertically aligned? Particularly |
| +/// the share icon." They were not: SF Symbols sit on a text baseline, and |
| +/// the share symbol's ink was 1.25 pt below its neighbours'. These render |
| +/// each control symbol as the controls draw it and measure where its ink |
| +/// is, so a table entry that drifts, a symbol added without one, or a |
| +/// style that puts the symbol back on a baseline fails here. |
| +@MainActor final class CallGlyphTests: XCTestCase { |
| + /// Half a point either way of the centre line. |
| + static let tolerance: CGFloat = 0.5 |
| + |
| + /// Every size the Mac's controls draw at: `bar`, `round` and |
| + /// `action`, drawn by AppKit as the screen draws them. The iPhone's |
| + /// sizes are drawn by UIKit at 3×, which this Mac cannot do; the |
| + /// iPhone's call-screen UI tests measure those on the simulator's own |
| + /// screenshots, against the same table. |
| + static let sizes: [(String, CallGlyphMetrics)] = [ |
| + ("bar", .bar), ("round", .round), ("action", .action), |
| + ] |
| + |
| + /// Every symbol in the table, at every size and weight the controls |
| + /// use on either platform: its ink's centroid on the square's centre. |
| + func testEverySymbolsInkIsOnTheCentreLine() throws { |
| + var failures: [String] = [] |
| + var worst: [String: CGFloat] = [:] |
| + for (name, metrics) in Self.sizes { |
| + for symbol in CallGlyph.opticalOffsets.keys.sorted() { |
| + let ink = try XCTUnwrap( |
| + Ink.measure(CallGlyph(symbol).environment(\.callGlyphMetrics, metrics)), |
| + "\(symbol) drew nothing at \(name): not a symbol this system has") |
| + XCTAssertEqual(ink.size.width, metrics.box, accuracy: 0.01, "\(symbol) \(name): the square") |
| + XCTAssertEqual(ink.size.height, metrics.box, accuracy: 0.01, "\(symbol) \(name): the square") |
| + if abs(ink.centroidOffset) > abs(worst[symbol] ?? 0) { worst[symbol] = ink.centroidOffset } |
| + if abs(ink.centroidOffset) > Self.tolerance { |
| + failures.append(String(format: "%@ at %@: %+.2f pt", symbol, name, ink.centroidOffset)) |
| + } |
| + } |
| + } |
| + for (symbol, offset) in worst.sorted(by: { $0.key < $1.key }) { |
| + print(String(format: "CallGlyph worst %@: %+.2f pt", symbol, offset)) |
| + } |
| + XCTAssert(failures.isEmpty, "ink off the centre line:\n" + failures.joined(separator: "\n") + "\n" + Self.table()) |
| + } |
| + |
| + /// The bar's own button, as the full-window call draws it: the style |
| + /// puts the symbol in the middle of the button, not on a baseline. |
| + func testTheBarButtonCentresItsSymbol() throws { |
| + for symbol in ["mic.fill", "square.and.arrow.up", "rectangle.bottomthird.inset.filled", "phone.down.fill"] { |
| + for tone in [CallOverlayButtonStyle.Tone.normal, .leave] { |
| + let button = Button("Control", glyph: symbol) {} |
| + .buttonStyle(CallActionStyle(overlay: tone)) |
| + .foregroundStyle(.white) |
| + // White on the red of Leave: count only what is whiter than it. |
| + let ink = try XCTUnwrap(Ink.measure(button, whiteOnly: tone == .leave), symbol) |
| + XCTAssertEqual(ink.size.height, 44, accuracy: 0.01) |
| + XCTAssertLessThanOrEqual( |
| + abs(ink.centroidOffset), Self.tolerance, |
| + String(format: "%@ (%@) is %+.2f pt off the button's centre", symbol, "\(tone)", ink.centroidOffset)) |
| + } |
| + } |
| + } |
| + |
| + /// Without the table the share symbol is where Paul found it, low: so |
| + /// the test above is measuring something the table fixes. |
| + func testTheShareSymbolIsLowWithoutItsCorrection() throws { |
| + var raw = CallGlyph("square.and.arrow.up") |
| + raw.corrected = false |
| + let ink = try XCTUnwrap(Ink.measure(raw.environment(\.callGlyphMetrics, .bar))) |
| + XCTAssertGreaterThan(ink.centroidOffset, 0.75, "square.and.arrow.up is not low uncorrected any more") |
| + XCTAssertLessThan(CallGlyph.opticalOffset("square.and.arrow.up"), 0, "and the table lifts it") |
| + } |
| + |
| + /// A fresh table from the symbols as this system draws them, over all |
| + /// the sizes: the middle of the range each symbol's |
| + /// correction can take, which is what to paste into |
| + /// `CallGlyph.opticalOffsets`. |
| + static func table() -> String { |
| + var lines = ["measured offsets (ems, negative up):"] |
| + for symbol in CallGlyph.opticalOffsets.keys.sorted() { |
| + var ems: [CGFloat] = [] |
| + for (_, metrics) in sizes { |
| + var raw = CallGlyph(symbol) |
| + raw.corrected = false |
| + if let ink = Ink.measure(raw.environment(\.callGlyphMetrics, metrics)) { |
| + ems.append(-ink.centroidOffset / metrics.points) |
| + } |
| + } |
| + let low = ems.min() ?? 0, high = ems.max() ?? 0 |
| + lines.append(String(format: " \"%@\": %.3f, // %.3f to %.3f", symbol, (low + high) / 2, low, high)) |
| + } |
| + return lines.joined(separator: "\n") |
| + } |
| + |
| + /// With CALLS_GLYPH_TUNE=1, the table to use. The pixel grid makes |
| + /// each symbol's worst offset a step function of its correction, so |
| + /// the smallest correction that wins can sit on the edge of a step, |
| + /// where a thousandth of an em flips it. This takes the middle of the |
| + /// widest run of corrections (in 2/1000 em steps) whose worst size is |
| + /// within 0.3 pt of the centre line, or, failing that, within 0.15 pt |
| + /// of the best any correction does. |
| + func testTuneTheTable() throws { |
| + try XCTSkipUnless(ProcessInfo.processInfo.environment["CALLS_GLYPH_TUNE"] == "1") |
| + var lines = ["tuned offsets (ems, negative up):"] |
| + for symbol in CallGlyph.opticalOffsets.keys.sorted() { |
| + let steps = Array(stride(from: -140, through: 100, by: 2)) |
| + let worsts: [CGFloat] = steps.map { step in |
| + var worst: CGFloat = 0 |
| + for (_, metrics) in Self.sizes { |
| + var glyph = CallGlyph(symbol) |
| + glyph.trial = CGFloat(step) / 1000 |
| + if let ink = Ink.measure(glyph.environment(\.callGlyphMetrics, metrics)) { |
| + worst = max(worst, abs(ink.centroidOffset)) |
| + } else { |
| + worst = .infinity |
| + } |
| + } |
| + return worst |
| + } |
| + let threshold = max(0.3, (worsts.min() ?? 0) + 0.15) |
| + var bestRun = (start: 0, length: 0), start = -1 |
| + for (i, worst) in worsts.enumerated() { |
| + if worst <= threshold { |
| + if start < 0 { start = i } |
| + if i - start + 1 > bestRun.length { bestRun = (start, i - start + 1) } |
| + } else { |
| + start = -1 |
| + } |
| + } |
| + let middle = bestRun.start + bestRun.length / 2 |
| + lines.append(String( |
| + format: " \"%@\": %.3f, // worst %.2f pt; %.3f to %.3f all within %.2f", symbol, |
| + CGFloat(steps[middle]) / 1000, worsts[middle], CGFloat(steps[bestRun.start]) / 1000, |
| + CGFloat(steps[bestRun.start + bestRun.length - 1]) / 1000, threshold)) |
| + } |
| + print(lines.joined(separator: "\n")) |
| + } |
| + |
| + func testPrintTheTable() { |
| + print(Self.table()) |
| + } |
| +} |
| + |
| +/// Where a view's ink is, as a window draws it: hosted in an AppKit |
| +/// window and drawn by AppKit (`cacheDisplay`), which places a symbol on |
| +/// the same pixels the screen does. An `ImageRenderer` does not: it |
| +/// rounded every half pixel the other way, and put the bar's symbols a |
| +/// pixel higher than the window it was meant to stand for. Weighted by |
| +/// alpha, or, over a coloured fill, by whiteness. |
| +struct Ink { |
| + /// The rendered size, in points. |
| + var size: CGSize |
| + /// The ink's centroid below the view's centre line, in points |
| + /// (negative: above it). |
| + var centroidOffset: CGFloat |
| + |
| + @MainActor static func measure(_ view: some View, whiteOnly: Bool = false) -> Ink? { |
| + let host = NSHostingView(rootView: view.environment(\.colorScheme, .dark)) |
| + let size = host.fittingSize |
| + guard size.width > 0, size.height > 0 else { return nil } |
| + host.frame = NSRect(origin: .zero, size: size) |
| + let window = NSWindow( |
| + contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.backgroundColor = .clear |
| + window.isOpaque = false |
| + window.contentView = host |
| + host.layoutSubtreeIfNeeded() |
| + defer { window.close() } |
| + guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return nil } |
| + host.cacheDisplay(in: host.bounds, to: rep) |
| + guard let image = rep.cgImage else { return nil } |
| + let w = image.width, h = image.height |
| + var pixels = [UInt8](repeating: 0, count: w * h * 4) |
| + guard |
| + let context = CGContext( |
| + data: &pixels, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, |
| + space: CGColorSpaceCreateDeviceRGB(), |
| + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) |
| + else { return nil } |
| + context.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h)) |
| + var total = 0.0, moment = 0.0 |
| + for y in 0..<h { |
| + for x in 0..<w { |
| + let i = (y * w + x) * 4 |
| + let weight: Double |
| + if whiteOnly { |
| + // The least of the three channels: white is high in all of |
| + // them, red is not. |
| + weight = max(0, Double(min(pixels[i], pixels[i + 1], pixels[i + 2])) - 60) |
| + } else { |
| + weight = Double(pixels[i + 3]) |
| + } |
| + total += weight |
| + moment += weight * (Double(y) + 0.5) |
| + } |
| + } |
| + guard total > 0 else { return nil } |
| + // Row 0 of the drawn bitmap is the top of the view. |
| + let scale = Double(h) / Double(size.height) |
| + let centroid = moment / total / scale |
| + return Ink(size: size, centroidOffset: CGFloat(centroid - Double(size.height) / 2)) |
| + } |
| +} |
| + |
| +/// A filmstrip tile's name label (0.4.1): on a narrow tile it stays |
| +/// inside the tile, the name truncating and "Muted" giving way to the |
| +/// slashed microphone alone, and on a wide one it says it all. |
| +@MainActor final class TileNameLabelTests: XCTestCase { |
| + private func width(_ label: TileNameLabel, in proposed: CGFloat) -> CGFloat { |
| + let host = NSHostingController(rootView: label) |
| + return host.sizeThatFits(in: CGSize(width: proposed, height: 100)).width |
| + } |
| + |
| + func testANarrowTileTruncatesTheNameAndKeepsTheMark() { |
| + let label = TileNameLabel(label: "Ana García Fernández", muted: true) |
| + let wide = width(label, in: 1000) |
| + let narrow = width(label, in: 100) |
| + XCTAssertLessThanOrEqual(narrow, 100, "the label stays inside a narrow tile") |
| + XCTAssertLessThan(narrow, wide) |
| + // What the mark takes with the word, and without it. |
| + let withWord = wide - width(TileNameLabel(label: "Ana García Fernández", muted: false), in: 1000) |
| + XCTAssertGreaterThan(withWord, 40, "a wide tile shows the word Muted as well as the symbol") |
| + let unmutedNarrow = width(TileNameLabel(label: "Ana García Fernández", muted: false), in: 100) |
| + XCTAssertLessThanOrEqual(unmutedNarrow, 100) |
| + } |
| + |
| + func testAShortNameIsNotTruncated() { |
| + let label = TileNameLabel(label: "Li", muted: true) |
| + XCTAssertEqual(width(label, in: 1000), width(label, in: 160), accuracy: 0.5) |
| + } |
| +} |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index 43e0258..6e407e6 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -333,7 +333,7 @@ struct PhoneCall: View { |
| HStack(spacing: 14) { |
| Button( |
| call.muted ? "Unmute microphone" : "Mute microphone", |
| - systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| + glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| ) { call.mute() } |
| // Space on a hardware keyboard (iPad, mostly). It is a |
| // UIKeyCommand without wantsPriorityOverSystemBehavior, so a |
| @@ -346,11 +346,11 @@ struct PhoneCall: View { |
| emphasized: call.muted)) |
| Button( |
| call.cameraOff ? "Turn camera on" : "Turn camera off", |
| - systemImage: call.cameraOff ? "video.slash.fill" : "video.fill" |
| + glyph: call.cameraOff ? "video.slash.fill" : "video.fill" |
| ) { call.toggleCamera() } |
| .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff)) |
| if call.sharingScreen { |
| - Button("Stop sharing your screen", systemImage: "rectangle.on.rectangle.slash") { |
| + Button("Stop sharing your screen", glyph: "rectangle.on.rectangle.slash") { |
| call.stopSharingScreen() |
| } |
| .buttonStyle(CallActionStyle(color: .orange, emphasized: true)) |
| @@ -360,12 +360,12 @@ struct PhoneCall: View { |
| .disabled(call.state?.phase != "admitted") |
| .accessibilityIdentifier("shareScreen") |
| } |
| - Button("Devices", systemImage: "slider.horizontal.3") { showingDevices = true } |
| + Button("Devices", glyph: "slider.horizontal.3") { showingDevices = true } |
| .buttonStyle(CallActionStyle(color: .teal)) |
| .accessibilityIdentifier("devices") |
| MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo)) |
| Spacer(minLength: 0) |
| - Button(chatButtonTitle, systemImage: "bubble.left.and.bubble.right.fill") { |
| + Button(chatButtonTitle, glyph: "bubble.left.and.bubble.right.fill") { |
| readChatCount = call.receivedChatCount |
| showingChat = true |
| }.buttonStyle(CallActionStyle(color: .purple)).accessibilityIdentifier("openChat") |
| @@ -375,7 +375,7 @@ struct PhoneCall: View { |
| .padding(4).background(.purple, in: Capsule()).accessibilityHidden(true) |
| } |
| } |
| - Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| + Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave) |
| .buttonStyle(CallActionStyle(color: .red, emphasized: true)) |
| .accessibilityIdentifier("leave") |
| }.padding(16).background(.bar) |
| @@ -419,6 +419,9 @@ struct PhoneVideoTile: View { |
| var pinned = false |
| /// A filmstrip thumbnail: the symbol alone for a missing picture. |
| var iconOnly = false |
| + /// The height of the name label and what is over it, which the |
| + /// placeholder stays above. |
| + @State private var labelBlock: CGFloat = 0 |
| var body: some View { |
| TileAudioReader(audio) { info in tile(info) } |
| } |
| @@ -428,16 +431,11 @@ struct PhoneVideoTile: View { |
| if let track, !cameraOff, placeholder == nil { |
| PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| - VStack(spacing: 6) { |
| - Image( |
| - systemName: cameraOff |
| - ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle" |
| - ) |
| - .font(.largeTitle) |
| - if let placeholder, !cameraOff, !iconOnly { |
| - Text(placeholder).font(.caption).multilineTextAlignment(.center).padding(.horizontal, 6) |
| - } |
| - }.foregroundStyle(.white.opacity(0.8)) |
| + TilePlaceholder( |
| + symbol: cameraOff |
| + ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle", |
| + words: cameraOff || iconOnly ? nil : placeholder, wordsFont: .caption, spacing: 6, |
| + reserve: labelBlock) |
| } |
| } |
| .overlay(alignment: .bottomLeading) { |
| @@ -459,6 +457,10 @@ struct PhoneVideoTile: View { |
| } |
| } |
| .padding(compact ? 6 : 8).padding(compact ? labelInsets : EdgeInsets()) |
| + // Room past the label's end for the speaking glow, when a long |
| + // name has it truncating. |
| + .padding(.trailing, 6) |
| + .tileLabelBlock($labelBlock) |
| } |
| .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } } |
| .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) |
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift| index 046fffe..cfdb26f 100644 |
| --- a/native/ios/Sources/PhoneFullScreenCall.swift |
| +++ b/native/ios/Sources/PhoneFullScreenCall.swift |
| @@ -338,8 +338,12 @@ extension PhoneCall { |
| Button { |
| showingMore = true |
| } label: { |
| - Label("More controls", systemImage: "ellipsis") |
| - .labelStyle(.iconOnly).rotationEffect(.degrees(90)) |
| + Label { |
| + Text("More controls") |
| + } icon: { |
| + CallGlyph("ellipsis", rotation: .degrees(90)) |
| + } |
| + .labelStyle(.iconOnly) |
| } |
| .buttonStyle(RoundCallButtonStyle(.normal)) |
| .overlay(alignment: .topTrailing) { |
| @@ -353,14 +357,14 @@ extension PhoneCall { |
| if micBesideMenu { |
| Button( |
| call.muted ? "Unmute microphone" : "Mute microphone", |
| - systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| + glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| ) { call.mute() } |
| .labelStyle(.iconOnly) |
| .buttonStyle(RoundCallButtonStyle(call.muted ? .off : .normal)) |
| .accessibilityIdentifier("mute") |
| } |
| Spacer(minLength: 0) |
| - Button("End call", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| + Button("End call", glyph: "phone.down.fill", role: .destructive, action: leave) |
| .labelStyle(.iconOnly) |
| .buttonStyle(RoundCallButtonStyle(.leave)) |
| .accessibilityIdentifier("leave") |
| @@ -385,6 +389,7 @@ struct RoundCallButtonStyle: ButtonStyle { |
| init(_ tone: CallOverlayButtonStyle.Tone) { self.tone = tone } |
| func makeBody(configuration: Configuration) -> some View { |
| configuration.label.font(.title2.weight(.semibold)) |
| + .modifier(CallGlyphSizing(.round)) |
| .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white) |
| .frame(width: PhoneCall.button, height: PhoneCall.button) |
| .background { |
| @@ -434,7 +439,7 @@ struct MoreControls: View { |
| tile(call.muted ? "Unmute" : "Mute") { |
| Button( |
| call.muted ? "Unmute microphone" : "Mute microphone", |
| - systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| + glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| ) { call.mute() } |
| .buttonStyle( |
| CallActionStyle( |
| @@ -445,13 +450,13 @@ struct MoreControls: View { |
| tile(call.cameraOff ? "Camera on" : "Camera off") { |
| Button( |
| call.cameraOff ? "Turn camera on" : "Turn camera off", |
| - systemImage: call.cameraOff ? "video.slash.fill" : "video.fill" |
| + glyph: call.cameraOff ? "video.slash.fill" : "video.fill" |
| ) { call.toggleCamera() } |
| .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff)) |
| .accessibilityIdentifier("camera") |
| } |
| tile("Flip") { |
| - Button("Flip camera", systemImage: "arrow.triangle.2.circlepath.camera") { |
| + Button("Flip camera", glyph: "arrow.triangle.2.circlepath.camera") { |
| if let next = CameraFlip.next(from: call.currentCamera, in: call.cameras) { |
| Task { await call.useCamera(next.id) } |
| } |
| @@ -462,7 +467,7 @@ struct MoreControls: View { |
| } |
| tile(call.sharingScreen ? "Stop sharing" : "Share screen") { |
| if call.sharingScreen { |
| - Button("Stop sharing your screen", systemImage: "rectangle.on.rectangle.slash") { |
| + Button("Stop sharing your screen", glyph: "rectangle.on.rectangle.slash") { |
| call.stopSharingScreen() |
| } |
| .buttonStyle(CallActionStyle(color: .orange, emphasized: true)) |
| @@ -474,12 +479,12 @@ struct MoreControls: View { |
| } |
| } |
| tile("Devices") { |
| - Button("Devices", systemImage: "slider.horizontal.3") { open(.devices) } |
| + Button("Devices", glyph: "slider.horizontal.3") { open(.devices) } |
| .buttonStyle(CallActionStyle(color: .teal)) |
| .accessibilityIdentifier("devices") |
| } |
| tile("Mic Mode") { |
| - Button("Mic Mode", systemImage: "waveform") { micModes.showPicker() } |
| + Button("Mic Mode", glyph: "waveform") { micModes.showPicker() } |
| .buttonStyle(CallActionStyle(color: .teal)) |
| .accessibilityIdentifier("micModeQuick") |
| .accessibilityHint(micModes.summary) |
| @@ -488,7 +493,11 @@ struct MoreControls: View { |
| Menu { |
| LayoutChoices(call: call, arrangement: arrangement, title: title) |
| } label: { |
| - Label("Video layout", systemImage: arrangement.layout.symbol) |
| + Label { |
| + Text("Video layout") |
| + } icon: { |
| + CallGlyph(arrangement.layout.symbol) |
| + } |
| } |
| .buttonStyle(CallActionStyle(color: .gray)) |
| .accessibilityIdentifier("videoLayout") |
| @@ -499,7 +508,7 @@ struct MoreControls: View { |
| } |
| } |
| tile(unread > 0 ? "Chat (\(unread))" : "Chat") { |
| - Button(unread == 0 ? "Chat" : "Chat (\(unread) new)", systemImage: "bubble.left.and.bubble.right.fill") { |
| + Button(unread == 0 ? "Chat" : "Chat (\(unread) new)", glyph: "bubble.left.and.bubble.right.fill") { |
| open(.chat) |
| } |
| .buttonStyle(CallActionStyle(color: .purple, emphasized: unread > 0)) |
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift| index e60eca2..f74b276 100644 |
| --- a/native/ios/UITests/CallsPhoneUITests.swift |
| +++ b/native/ios/UITests/CallsPhoneUITests.swift |
| @@ -1,3 +1,4 @@ |
| +import UIKit |
| import XCTest |
| final class CallsPhoneUITests: XCTestCase { |
| @@ -396,4 +397,108 @@ final class CallsPhoneUITests: XCTestCase { |
| XCTAssertTrue(app.buttons["moreControls"].isHittable) |
| XCTAssertLessThan(app.buttons["moreControls"].frame.midX, app.buttons["leave"].frame.midX) |
| } |
| + |
| + // MARK: Glyphs (0.4.1) |
| + |
| + /// Paul, on the 0.4.0 bar: "Are these vertically aligned?" Every |
| + /// symbol a call control draws goes through CallGlyph and its optical |
| + /// table; the Mac's tests measure it drawn by AppKit, and this measures |
| + /// it as the iPhone draws it: the round bar buttons, the microphone |
| + /// beside the menu, and every button in the controls sheet, each |
| + /// symbol's ink within half a point of its button's centre line. |
| + @MainActor func testControlGlyphsAreOnTheirCentreLines() throws { |
| + XCUIDevice.shared.orientation = .portrait |
| + var report: [String] = [] |
| + var failures: [String] = [] |
| + func measure(_ app: XCUIApplication, _ ids: [String], _ label: String) { |
| + let shot = XCUIScreen.main.screenshot() |
| + for id in ids { |
| + let button = app.buttons[id].firstMatch |
| + guard button.exists else { |
| + failures.append("\(label): \(id) is not there") |
| + continue |
| + } |
| + guard let offset = GlyphInk.offset(in: shot.image, frame: button.frame) else { |
| + failures.append("\(label): \(id) drew no symbol") |
| + continue |
| + } |
| + report.append(String(format: "%@ %@: %+.2f pt", label, id, offset)) |
| + if abs(offset) > 0.5 { failures.append(String(format: "%@ %@: %+.2f pt", label, id, offset)) } |
| + } |
| + } |
| + let bar = launchCall( |
| + "focus", |
| + ["-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", "black", "-CallsMicBesideMenu", "YES"], |
| + dark: true) |
| + XCTAssertTrue(bar.buttons["moreControls"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1) |
| + measure(bar, ["moreControls", "mute", "leave"], "bar") |
| + bar.terminate() |
| + for dark in [false, true] { |
| + let sheet = launchCall("focus", ["-CallsFixtureControls", "shown", "-CallsFixtureMore", "YES"], dark: dark) |
| + XCTAssertTrue(sheet.buttons["devices"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1.5) |
| + measure( |
| + sheet, ["mute", "camera", "flipCamera", "devices", "micModeQuick", "videoLayout", "Share meeting link", "openChat"], |
| + dark ? "sheet dark" : "sheet light") |
| + sheet.terminate() |
| + } |
| + let attachment = XCTAttachment(string: report.joined(separator: "\n")) |
| + attachment.name = "glyph centroids" |
| + attachment.lifetime = .keepAlways |
| + add(attachment) |
| + try FileManager.default.createDirectory(at: snapshotDirectory, withIntermediateDirectories: true) |
| + try report.joined(separator: "\n").write( |
| + to: snapshotDirectory.appendingPathComponent("iphone-glyph-centroids.txt"), atomically: true, |
| + encoding: .utf8) |
| + XCTAssert(failures.isEmpty, failures.joined(separator: "\n") + "\n" + report.joined(separator: "\n")) |
| + } |
| +} |
| + |
| +/// Where a button's symbol's ink is: the pixels of its frame weighted by |
| +/// how far their colour is from the button's own background, sampled |
| +/// just inside its left edge at mid-height (a round button's glass, a |
| +/// tinted square's fill, a red capsule), as the tests on the Mac do. |
| +enum GlyphInk { |
| + static func offset(in image: UIImage, frame: CGRect) -> CGFloat? { |
| + guard let cg = image.cgImage else { return nil } |
| + let scale = CGFloat(cg.width) / image.size.width |
| + let rect = CGRect( |
| + x: (frame.minX * scale).rounded(), y: (frame.minY * scale).rounded(), |
| + width: (frame.width * scale).rounded(), height: (frame.height * scale).rounded()) |
| + guard let crop = cg.cropping(to: rect) else { return nil } |
| + let w = crop.width, h = crop.height |
| + var px = [UInt8](repeating: 0, count: w * h * 4) |
| + guard |
| + let ctx = CGContext( |
| + data: &px, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, |
| + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) |
| + else { return nil } |
| + ctx.draw(crop, in: CGRect(x: 0, y: 0, width: w, height: h)) |
| + // The background: the median of a short column just inside the |
| + // left edge, clear of the symbol and of a rounded corner. |
| + let x = max(2, Int(4 * scale)) |
| + var samples: [(Int, Int, Int)] = [] |
| + for y in (h / 2 - Int(3 * scale))...(h / 2 + Int(3 * scale)) { |
| + let i = (y * w + x) * 4 |
| + samples.append((Int(px[i]), Int(px[i + 1]), Int(px[i + 2]))) |
| + } |
| + samples.sort { $0.0 + $0.1 + $0.2 < $1.0 + $1.1 + $1.2 } |
| + let bg = samples[samples.count / 2] |
| + var total = 0.0, moment = 0.0 |
| + // Only the middle of the button, where the symbol is: a square's |
| + // rounded corners and a circle's edge stay out of it. |
| + let inset = Int(8 * scale) |
| + for y in inset..<(h - inset) { |
| + for x in inset..<(w - inset) { |
| + let i = (y * w + x) * 4 |
| + let d = abs(Int(px[i]) - bg.0) + abs(Int(px[i + 1]) - bg.1) + abs(Int(px[i + 2]) - bg.2) |
| + let weight = Double(max(0, d - 60)) |
| + total += weight |
| + moment += weight * (Double(y) + 0.5) |
| + } |
| + } |
| + guard total > 0 else { return nil } |
| + return CGFloat(moment / total) / scale - frame.height / 2 + (rect.minY / scale - frame.minY) |
| + } |
| } |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 4f62e54..c1354ca 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.4.0' |
| + MARKETING_VERSION: '0.4.1' |
| 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 |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 6e9d448..eb3b5ff 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -31,7 +31,8 @@ |
| // candidates are the only ones there are, and an obfuscated one connects |
| // to nobody; and --auto-accept-this-tab-capture answers getDisplayMedia |
| // with no picker, which the fake-ui flag does not cover (§5.4). |
| -import { writeSync, mkdirSync } from 'node:fs'; |
| +import { writeSync, mkdirSync, writeFileSync } from 'node:fs'; |
| +import { inflateSync } from 'node:zlib'; |
| import { createInterface } from 'node:readline'; |
| import { chromium } from 'playwright'; |
| @@ -1267,6 +1268,80 @@ function mediaLive(page) { |
| return page.evaluate(() => ((document.getElementById('media-live') || {}).textContent || '').trim()); |
| } |
| +// ── ink ─────────────────────────────────────────────────────────────── |
| +// |
| +// Where a glyph's ink is, read off a screenshot the way Paul read the |
| +// Mac bar's (0.4.1): the centroid of the pixels brighter than the pane |
| +// under them, weighted by how much brighter. The PNG is Chromium's own |
| +// (8-bit, RGB or RGBA, not interlaced), so a decoder this small reads it. |
| + |
| +function decodePNG(buf) { |
| + let at = 8; |
| + let width = 0, height = 0, type = 0; |
| + const idat = []; |
| + while (at < buf.length) { |
| + const len = buf.readUInt32BE(at); |
| + const kind = buf.toString('latin1', at + 4, at + 8); |
| + const body = buf.subarray(at + 8, at + 8 + len); |
| + if (kind === 'IHDR') { |
| + width = body.readUInt32BE(0); |
| + height = body.readUInt32BE(4); |
| + if (body[8] !== 8 || body[12] !== 0) throw new Error('decodePNG: not 8-bit non-interlaced'); |
| + type = body[9]; |
| + } else if (kind === 'IDAT') idat.push(body); |
| + at += 12 + len; |
| + } |
| + const bpp = type === 6 ? 4 : type === 2 ? 3 : 0; |
| + if (!bpp) throw new Error('decodePNG: colour type ' + type); |
| + const raw = inflateSync(Buffer.concat(idat)); |
| + const stride = width * bpp; |
| + const px = Buffer.alloc(height * stride); |
| + for (let y = 0; y < height; y++) { |
| + const f = raw[y * (stride + 1)]; |
| + const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); |
| + for (let x = 0; x < stride; x++) { |
| + const a = x >= bpp ? px[y * stride + x - bpp] : 0; |
| + const b = y > 0 ? px[(y - 1) * stride + x] : 0; |
| + const c = x >= bpp && y > 0 ? px[(y - 1) * stride + x - bpp] : 0; |
| + let v = row[x]; |
| + if (f === 1) v += a; |
| + else if (f === 2) v += b; |
| + else if (f === 3) v += (a + b) >> 1; |
| + else if (f === 4) { |
| + const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); |
| + v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; |
| + } |
| + px[y * stride + x] = v & 255; |
| + } |
| + } |
| + const lum = (x, y) => { |
| + const i = y * stride + x * bpp; |
| + return 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2]; |
| + }; |
| + return { width, height, lum }; |
| +} |
| + |
| +// inkCentroid screenshots `box` (page CSS pixels, at the page's own |
| +// device scale) and returns the ink's vertical centroid in page CSS |
| +// pixels, or null if there was no ink. |
| +async function inkCentroid(page, box, scale = 1) { |
| + const pad = 2; |
| + const clip = { x: box.x - pad, y: box.y - pad, width: box.width + 2 * pad, height: box.height + 2 * pad }; |
| + const img = decodePNG(await page.screenshot({ clip })); |
| + let floor = 255; |
| + for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) floor = Math.min(floor, img.lum(x, y)); |
| + let total = 0, moment = 0; |
| + for (let y = 0; y < img.height; y++) { |
| + for (let x = 0; x < img.width; x++) { |
| + const w = Math.max(0, img.lum(x, y) - floor - 8); |
| + total += w; |
| + moment += w * (y + 0.5); |
| + } |
| + } |
| + if (!total) return null; |
| + return clip.y + moment / total / scale; |
| +} |
| + |
| // ── the scenarios ──────────────────────────────────────────────────── |
| const run = { |
| @@ -4630,6 +4705,102 @@ const run = { |
| await noMedia.close(); |
| }, |
| + // The control bar's glyphs and the filmstrip's labels (0.4.1). Paul |
| + // measured the Mac bar's glyphs and found the share symbol low; the |
| + // web bar is held to the same rule: every glyph's ink centred on the |
| + // bar's centre line, within half a pixel. And a filmstrip tile's |
| + // caption stays inside its tile, its muted mark visible, with the |
| + // camera-off cover's words never under it, down to a 320 px screen. |
| + // CALLS_BAR_SNAPSHOTS=<dir> keeps pictures and the measurements. |
| + async glyphs() { |
| + const out = process.env.CALLS_BAR_SNAPSHOTS; |
| + if (out) mkdirSync(out, { recursive: true }); |
| + const report = []; |
| + const ctxA = await newContext('glyphs A', { cookie: cookieA }); |
| + const ctxB = await newContext('glyphs B', { cookie: cookieB }); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + const pair = await assertPair(pageA, pageB, 'glyphs A', 'glyphs B'); |
| + if (!pair) throw new Error('glyphs call did not connect'); |
| + const b = pair.b.peer; |
| + // B mutes and turns the camera off, so B's tile carries both marks. |
| + await pageB.locator('#mute').click(); |
| + await pageB.locator('#camera').click(); |
| + const tileB = pageA.locator('#tiles > figure[data-tile="' + b + '"]'); |
| + check(await until(async () => await tileB.locator('.calls-tile-muted').isVisible() && |
| + await tileB.locator('.calls-tile-off').isVisible(), 10000), 'B shows muted and camera off on A'); |
| + await pageA.emulateMedia({ reducedMotion: 'reduce' }); |
| + await pageA.locator('#layout-speaker').click(); |
| + // Pin yourself, so B is in the filmstrip. |
| + await pageA.locator('#tiles > figure[data-tile="self"] .calls-tool-pin').click({ force: true }); |
| + check(await until(async () => (await pageA.locator('#tiles > [data-stage]').getAttribute('data-tile')) === 'self', 3000), |
| + 'A pinned to the stage, B in the filmstrip'); |
| + await pageA.mouse.move(1, 1); |
| + |
| + for (const width of [1280, 760]) { |
| + await pageA.setViewportSize({ width, height: 800 }); |
| + await sleep(300); |
| + await pageA.locator('#controls').scrollIntoViewIfNeeded(); |
| + const bar = await pageA.locator('#controls').boundingBox(); |
| + // The centre line of the row a button is in: the bar's rows |
| + // stretch their buttons to one height, so it is the button's own |
| + // centre (and the bar's, while the bar is one row). |
| + const glyphs = await pageA.locator('#controls button:visible svg.icon').evaluateAll((svgs) => svgs.map((svg) => { |
| + const r = svg.getBoundingClientRect(); |
| + const row = svg.closest('button').getBoundingClientRect(); |
| + return { id: svg.closest('button').id || 'share-link', x: r.x, y: r.y, width: r.width, height: r.height, |
| + centre: row.y + row.height / 2 }; |
| + })); |
| + check(glyphs.length >= 5, width + ' px: the bar has its glyphs (' + glyphs.length + ')'); |
| + for (const g of glyphs) { |
| + const ink = await inkCentroid(pageA, g); |
| + const centre = g.centre; |
| + const off = ink === null ? NaN : ink - centre; |
| + report.push({ width, glyph: g.id, inkCentroid: ink, rowCentre: centre, barCentre: bar.y + bar.height / 2, offset: off }); |
| + check(Math.abs(off) <= 0.5, width + ' px: ' + g.id + "'s ink is on the bar's centre line (" + |
| + (off >= 0 ? '+' : '') + off.toFixed(2) + ' px)'); |
| + } |
| + if (out) await pageA.screenshot({ path: out + '/web-bar-' + width + '.png', clip: bar }); |
| + } |
| + |
| + // A name as long as a real one can be, so a narrow tile has to |
| + // truncate it. |
| + await tileB.locator('figcaption > span').first().evaluate((n) => { n.textContent = 'Ana García Fernández'; }); |
| + for (const width of [1280, 760, 390, 320]) { |
| + await pageA.setViewportSize({ width, height: width < 500 ? 844 : 800 }); |
| + await sleep(300); |
| + const t = await tileB.evaluate((fig) => { |
| + const box = (el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; }; |
| + const cap = fig.querySelector('figcaption'); |
| + const name = cap.querySelector('span'); |
| + const muted = fig.querySelector('.calls-tile-muted'); |
| + const mark = muted.querySelector('svg'); |
| + const off = fig.querySelector('.calls-tile-off'); |
| + const ink = Array.from(off.children).filter((c) => getComputedStyle(c).display !== 'none' && c.getClientRects().length); |
| + return { |
| + tile: box(fig), caption: box(cap), mark: mark ? box(mark) : null, |
| + markShown: !!mark && getComputedStyle(mark).display !== 'none' && mark.getBoundingClientRect().width > 0, |
| + nameFits: name.scrollWidth <= name.clientWidth + 1 || getComputedStyle(name).textOverflow === 'ellipsis', |
| + cover: ink.map(box), |
| + label: fig.getAttribute('aria-label') || '', |
| + mutedText: muted.textContent.trim(), |
| + }; |
| + }); |
| + report.push({ width, filmstripTile: t }); |
| + check(t.caption.right <= t.tile.right + 0.5 && t.caption.left >= t.tile.left - 0.5, |
| + width + ' px: the caption stays inside the filmstrip tile'); |
| + check(t.markShown && t.mark.right <= t.tile.right, width + ' px: the muted mark is visible'); |
| + check(t.nameFits, width + ' px: the name fits or ends in an ellipsis'); |
| + check(t.cover.every((c) => c.bottom <= t.caption.top), width + " px: the camera-off cover is clear of the caption (" + |
| + JSON.stringify(t.cover.map((c) => Math.round(c.bottom))) + ' vs ' + Math.round(t.caption.top) + ')'); |
| + check(/muted/i.test(t.mutedText), width + ' px: "Muted" is still in the tile for assistive technology'); |
| + if (out) await pageA.screenshot({ path: out + '/web-filmstrip-' + width + '.png', fullPage: true }); |
| + } |
| + if (out) writeFileSync(out + '/web-measurements.json', JSON.stringify(report, null, 2)); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| async scriptless() { |
| const ctx = await newContext('scriptless', { cookie: cookieA, javaScriptEnabled: false }); |
| const sentence = 'The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.'; |