| 1 | import SwiftUI |
| 2 | |
| 3 | // 0.4.3: the speaking light on a name label is the warm rim — "T5, warm |
| 4 | // soft breath", chosen from three rounds of mock-ups on 2026-09-28 and |
| 5 | // drawn to match that mock-up (speaking-indicator-options/index.html), |
| 6 | // whose box-shadow recipe this is. Over the label's own glass, in the |
| 7 | // label's own shape, four layers: |
| 8 | // |
| 9 | // 1. the rim: a 1 pt line just inside the edge; |
| 10 | // 2. a top highlight — none, in T5; |
| 11 | // 3. the inner light: the edge's light bleeding inwards; |
| 12 | // 4. the outer breath: the same light just outside the edge, only once |
| 13 | // the voice is loud enough to push it out. |
| 14 | // |
| 15 | // All warm white (hue 38°, saturation 80 %), a little deeper for each layer |
| 16 | // further from the edge. `on` is whether the light is on at all (lit, per |
| 17 | // SpeakingGlow, or still fading from it); `level` is its smoothed level, |
| 18 | // 0...1. The numbers are CSS's: a blur is a box-shadow's blur radius, |
| 19 | // which is twice the Gaussian's standard deviation; SwiftUI's blur radius |
| 20 | // is the deviation itself, so it is drawn at half. |
| 21 | |
| 22 | /// The warm rim's four layers at one moment: how opaque each is and how |
| 23 | /// far the blurred ones spread. |
| 24 | public struct WarmRim: Equatable, Sendable { |
| 25 | /// The rim's line, 1 pt inside the edge. |
| 26 | public static let rimWidth: CGFloat = 1 |
| 27 | public static let hue: Double = 38 |
| 28 | public static let saturation: Double = 0.80 |
| 29 | public static let rimLightness: Double = 0.82 |
| 30 | public static let innerLightness: Double = 0.72 |
| 31 | public static let outerLightness: Double = 0.66 |
| 32 | |
| 33 | public let on: Bool |
| 34 | public let level: Double |
| 35 | /// 1. The rim: 0.10 lit, up to 0.55 at the loudest. |
| 36 | public let rimOpacity: Double |
| 37 | /// 2. The top highlight's: none in T5, and so never drawn. |
| 38 | public let topOpacity: Double |
| 39 | /// 3. The inner light: 0.06 lit, up to 0.36; blur 3 pt up to 9 pt. |
| 40 | public let innerOpacity: Double |
| 41 | public let innerBlur: Double |
| 42 | /// 4. The outer breath: nothing until the voice rises, up to 0.25; |
| 43 | /// blur 1 pt up to 8 pt. |
| 44 | public let outerOpacity: Double |
| 45 | public let outerBlur: Double |
| 46 | |
| 47 | public init(on: Bool, level: Double) { |
| 48 | let o: Double = on ? 1 : 0 |
| 49 | let l = min(1, max(0, level.isFinite ? level : 0)) |
| 50 | self.on = on |
| 51 | self.level = l |
| 52 | rimOpacity = o * (0.10 + l * 0.45) |
| 53 | topOpacity = 0 |
| 54 | innerOpacity = o * (0.06 + l * 0.30) |
| 55 | innerBlur = 3 + l * 6 |
| 56 | outerOpacity = o * (0 + l * 0.25) |
| 57 | outerBlur = 1 + l * 7 |
| 58 | } |
| 59 | |
| 60 | public static let off = WarmRim(on: false, level: 0) |
| 61 | |
| 62 | /// sRGB components of hsl(h°, s, l), all 0...1. |
| 63 | public static func rgb(hue: Double, saturation s: Double, lightness l: Double) -> ( |
| 64 | red: Double, green: Double, blue: Double |
| 65 | ) { |
| 66 | let c = (1 - abs(2 * l - 1)) * s |
| 67 | let h = (hue.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) / 60 |
| 68 | let x = c * (1 - abs(h.truncatingRemainder(dividingBy: 2) - 1)) |
| 69 | let (r, g, b): (Double, Double, Double) = |
| 70 | switch h { |
| 71 | case ..<1: (c, x, 0) |
| 72 | case ..<2: (x, c, 0) |
| 73 | case ..<3: (0, c, x) |
| 74 | case ..<4: (0, x, c) |
| 75 | case ..<5: (x, 0, c) |
| 76 | default: (c, 0, x) |
| 77 | } |
| 78 | let m = l - c / 2 |
| 79 | return (r + m, g + m, b + m) |
| 80 | } |
| 81 | |
| 82 | static func color(lightness: Double) -> Color { |
| 83 | let c = rgb(hue: hue, saturation: saturation, lightness: lightness) |
| 84 | return Color(.sRGB, red: c.red, green: c.green, blue: c.blue) |
| 85 | } |
| 86 | static let rimColor = color(lightness: rimLightness) |
| 87 | static let innerColor = color(lightness: innerLightness) |
| 88 | static let outerColor = color(lightness: outerLightness) |
| 89 | } |
| 90 | |
| 91 | extension View { |
| 92 | /// The speaking light on a name label (0.4.3): the warm rim in the |
| 93 | /// label's own `shape`, drawn over it, lit while that person speaks and |
| 94 | /// following their voice, from `glow` (nil draws nothing: a screen's |
| 95 | /// label). Outside the label's layout, so nothing moves when it lights. |
| 96 | public func speakingRim<S: InsettableShape>(_ glow: GlowLevel?, in shape: S) -> some View { |
| 97 | overlay { |
| 98 | if let glow { SpeakingRimOverlay(level: glow, shape: shape) } |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | /// The rim itself. It redraws only while the light is moving: a timeline |
| 104 | /// that runs from a new reading until the light has arrived where that |
| 105 | /// reading sent it (`SpeakingGlow.settles`), at up to 30 Hz, and stops. In |
| 106 | /// silence it draws nothing and nothing runs. With Reduce Motion there is |
| 107 | /// no timeline at all: a steady rim while lit. |
| 108 | struct SpeakingRimOverlay<S: InsettableShape>: View { |
| 109 | @ObservedObject var level: GlowLevel |
| 110 | let shape: S |
| 111 | @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| 112 | |
| 113 | var body: some View { |
| 114 | let glow = level.glow |
| 115 | if reduceMotion { |
| 116 | WarmRimLayers(rim: glow.rim(at: 0, reduceMotion: true), shape: shape) |
| 117 | } else { |
| 118 | TimelineView(GlowSchedule(until: Self.date(uptime: glow.settles))) { _ in |
| 119 | WarmRimLayers(rim: glow.rim(at: ProcessInfo.processInfo.systemUptime), shape: shape) |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | private static func date(uptime: Double) -> Date { |
| 125 | Date().addingTimeInterval(uptime - ProcessInfo.processInfo.systemUptime) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | /// The four layers at one moment, over `shape`; nothing at all while off. |
| 130 | struct WarmRimLayers<S: InsettableShape>: View { |
| 131 | let rim: WarmRim |
| 132 | let shape: S |
| 133 | /// How far past the edge the inner light's source reaches: far enough |
| 134 | /// that its blur, at the widest, fades inside as an infinite edge would. |
| 135 | private static var band: CGFloat { 16 } |
| 136 | |
| 137 | var body: some View { |
| 138 | if rim.on { |
| 139 | ZStack { |
| 140 | // 4. The outer breath: the shape's light, blurred, only outside it. |
| 141 | if rim.outerOpacity > 0.001 { |
| 142 | shape.fill(WarmRim.outerColor.opacity(rim.outerOpacity)) |
| 143 | .blur(radius: rim.outerBlur / 2) |
| 144 | .mask(OutsideOf(shape: shape, margin: 24).fill(style: FillStyle(eoFill: true))) |
| 145 | } |
| 146 | // 3. The inner light: an inset shadow — light from a band all round |
| 147 | // outside the edge, blurred, and only what falls inside it kept. |
| 148 | shape.inset(by: -Self.band) |
| 149 | .strokeBorder(WarmRim.innerColor.opacity(rim.innerOpacity), lineWidth: Self.band) |
| 150 | .blur(radius: rim.innerBlur / 2) |
| 151 | .clipShape(shape) |
| 152 | // 1. The rim. |
| 153 | shape.strokeBorder(WarmRim.rimColor.opacity(rim.rimOpacity), lineWidth: WarmRim.rimWidth) |
| 154 | } |
| 155 | .allowsHitTesting(false) |
| 156 | .accessibilityHidden(true) |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | /// Everything within `margin` of a shape's frame except the shape itself, |
| 162 | /// filled even-odd: where the outer breath may show. |
| 163 | struct OutsideOf<S: Shape>: Shape { |
| 164 | let shape: S |
| 165 | let margin: CGFloat |
| 166 | func path(in rect: CGRect) -> Path { |
| 167 | var path = Path(rect.insetBy(dx: -margin, dy: -margin)) |
| 168 | path.addPath(shape.path(in: rect)) |
| 169 | return path |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | /// A tile's name over the picture, on the Mac and the iPhone alike: the |
| 174 | /// glass pill (or, compact, the label with its muted mark), with the warm |
| 175 | /// rim on it while that person speaks. |
| 176 | public struct TileSpeakingName: View { |
| 177 | let label: String |
| 178 | let muted: Bool |
| 179 | let compact: Bool |
| 180 | let glow: GlowLevel? |
| 181 | public init(label: String, muted: Bool, compact: Bool, glow: GlowLevel?) { |
| 182 | self.label = label |
| 183 | self.muted = muted |
| 184 | self.compact = compact |
| 185 | self.glow = glow |
| 186 | } |
| 187 | public var body: some View { |
| 188 | Group { |
| 189 | if compact { |
| 190 | TileNameLabel(label: label, muted: muted) |
| 191 | } else { |
| 192 | Text(label).font(.callout.bold()).foregroundStyle(.white) |
| 193 | .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6) |
| 194 | .callOverlaySurface(Capsule()) |
| 195 | } |
| 196 | } |
| 197 | .speakingRim(glow, in: Capsule()) |
| 198 | } |
| 199 | } |
| 200 | |