oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2
3/// A call control's symbol, centred on its button by its ink (0.4.1).
4///
5/// SF Symbols are laid out to sit on a line of text, not to be centred in
6/// a circle: each is placed by the font's baseline and cap height, and
7/// how its weight is spread inside that box is its own business. In a row
8/// of borderless buttons that shows. Paul measured the 0.4.0 bar: the
9/// share symbol's ink sat 1.25 pt below the others', because its weight
10/// is the tray at the bottom and the arrow above it is a thin line.
11///
12/// So every symbol a call's controls draw goes through here: set at one
13/// point size and weight (`CallGlyphMetrics`, from the button's style),
14/// centred in one square the same for every symbol, and moved by its
15/// entry in `opticalOffsets`, so that its ink's centre of mass lies on
16/// the square's centre line. `CallGlyphTests` renders each symbol at
17/// every size the controls use and holds that to within half a point.
18public struct CallGlyph: View {
19 public let symbol: String
20 /// The ellipsis stood on end, for the iPhone's ⋯ button. The optical
21 /// offset is along the symbol's own vertical, so a quarter turn makes
22 /// it a sideways one; the ellipsis is symmetric, so there is none.
23 public var rotation: Angle
24 /// Off only to measure where SF Symbols alone would put the ink;
25 /// `trial` measures a correction (in ems) before it goes in the table.
26 var corrected = true
27 var trial: CGFloat? = nil
28 @Environment(\.callGlyphMetrics) private var metrics
29 @Environment(\.displayScale) private var displayScale
30
31 public init(_ symbol: String, rotation: Angle = .zero) {
32 self.symbol = symbol
33 self.rotation = rotation
34 }
35
36 public var body: some View {
37 PixelSquare(
38 side: metrics.box,
39 lift: (trial ?? (corrected ? Self.opticalOffset(symbol) : 0)) * metrics.points,
40 scale: displayScale
41 ) {
42 Image(systemName: symbol)
43 .font(.system(size: metrics.points, weight: metrics.weight))
44 .imageScale(.medium)
45 }
46 .rotationEffect(rotation)
47 }
48
49 /// How far each symbol moves to put its ink's centroid on the centre
50 /// line, in ems (a fraction of the point size, so it holds at every
51 /// size); negative is up. Tuned by `CallGlyphTests.testTuneTheTable`
52 /// (run with CALLS_GLYPH_TUNE=1), which prints a fresh table; a symbol
53 /// added to the controls needs an entry, or it is drawn where SF
54 /// Symbols puts it and nothing measures it.
55 public static let opticalOffsets: [String: CGFloat] = [
56 "mic.fill": -0.016,
57 "mic.slash.fill": -0.016,
58 "video.fill": -0.008,
59 "video.slash.fill": -0.008,
60 "rectangle.on.rectangle": 0.016,
61 "rectangle.on.rectangle.slash": 0.014,
62 "slider.horizontal.3": 0.000,
63 "rectangle.bottomthird.inset.filled": -0.070,
64 "square.grid.2x2": -0.008,
65 "square.and.arrow.up": -0.110,
66 "bubble.left.and.bubble.right.fill": 0.074,
67 "phone.down.fill": 0.014,
68 "ellipsis": 0.000,
69 "arrow.triangle.2.circlepath.camera": -0.042,
70 "waveform": -0.008,
71 "arrow.down.right.and.arrow.up.left": -0.008,
72 ]
73
74 public static func opticalOffset(_ symbol: String) -> CGFloat {
75 opticalOffsets[symbol] ?? 0
76 }
77}
78
79/// The square a symbol is centred in, with the symbol placed on whole
80/// pixels by this layout rather than by the renderer. A symbol's box is
81/// a fraction of a point tall, so centring it leaves it half a pixel
82/// off the grid, and which way that half pixel then rounds is the
83/// renderer's choice: a window rounded it one way and an off-screen
84/// render the other, a pixel apart. Rounding here makes the two agree,
85/// and makes what the tests measure what the screen shows.
86struct PixelSquare: Layout {
87 let side: CGFloat
88 let lift: CGFloat
89 let scale: CGFloat
90
91 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
92 CGSize(width: side, height: side)
93 }
94
95 func placeSubviews(
96 in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()
97 ) {
98 let scale = max(scale, 1)
99 func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
100 for subview in subviews {
101 let size = subview.sizeThatFits(.unspecified)
102 subview.place(
103 at: CGPoint(
104 x: bounds.minX + snap((side - size.width) / 2),
105 y: bounds.minY + snap((side - size.height) / 2 + lift)),
106 proposal: ProposedViewSize(size))
107 }
108 }
109}
110
111/// The size a button's style sets its symbol at: one point size and
112/// weight for every symbol in a row, and the square they are centred in.
113public struct CallGlyphMetrics: Equatable, Sendable {
114 public var points: CGFloat
115 public var weight: Font.Weight
116 public var box: CGFloat
117
118 public init(points: CGFloat, weight: Font.Weight) {
119 self.points = points
120 self.weight = weight
121 // Wide enough for the widest (the two chat bubbles), and the same
122 // for all of them; an even number of points, so that centred in a
123 // button of 44 or 56 it starts on a whole point.
124 self.box = (points * 0.95).rounded() * 2
125 }
126
127 // The text styles the buttons used before 0.4.1, as point sizes, so no
128 // symbol changed size: macOS's title3 and title2 are 15 and 17 pt,
129 // iOS's 20 and 22 pt at the default text size.
130 #if os(macOS)
131 static let title3: CGFloat = 15
132 static let title2: CGFloat = 17
133 #else
134 static let title3: CGFloat = 20
135 static let title2: CGFloat = 22
136 #endif
137
138 /// The full-window bar's buttons.
139 public static let bar = CallGlyphMetrics(points: title3, weight: .medium)
140 /// A round button: the iPhone's ⋯, microphone and End call.
141 public static let round = CallGlyphMetrics(points: title2, weight: .semibold)
142 /// The classic row, the mini window, and the iPhone's controls sheet.
143 public static let action = CallGlyphMetrics(points: title3, weight: .regular)
144
145 /// `self`, scaled for the person's text size.
146 func scaled(_ factor: CGFloat) -> CallGlyphMetrics {
147 CallGlyphMetrics(points: (points * factor).rounded(), weight: weight)
148 }
149}
150
151/// Sets the `CallGlyph`s inside at `metrics`, grown or shrunk with the
152/// person's text size as the text style the buttons used to set did.
153public struct CallGlyphSizing: ViewModifier {
154 let metrics: CallGlyphMetrics
155 @ScaledMetric(relativeTo: .title3) private var textScale: CGFloat = 1
156 public init(_ metrics: CallGlyphMetrics) { self.metrics = metrics }
157 public func body(content: Content) -> some View {
158 content.environment(\.callGlyphMetrics, textScale == 1 ? metrics : metrics.scaled(textScale))
159 }
160}
161
162private struct CallGlyphMetricsKey: EnvironmentKey {
163 static let defaultValue = CallGlyphMetrics.action
164}
165
166extension EnvironmentValues {
167 /// See `CallGlyphMetrics`. Set by the call buttons' styles.
168 public var callGlyphMetrics: CallGlyphMetrics {
169 get { self[CallGlyphMetricsKey.self] }
170 set { self[CallGlyphMetricsKey.self] = newValue }
171 }
172}
173
174extension Button where Label == SwiftUI.Label<Text, CallGlyph> {
175 /// A call control: its title for VoiceOver and the tooltip, and
176 /// `glyph` drawn as a `CallGlyph`.
177 public init(
178 _ title: String, glyph: String, role: ButtonRole? = nil, action: @escaping () -> Void
179 ) {
180 self.init(role: role, action: action) {
181 SwiftUI.Label { Text(title) } icon: { CallGlyph(glyph) }
182 }
183 }
184}
185