oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1#if os(macOS)
2 import CoreGraphics
3 import Foundation
4
5 /// Where the mini window sits on screen. Four corners and nothing in
6 /// between, like FaceTime's and iOS's picture in picture: a window that
7 /// can rest anywhere ends up resting over whatever you were about to
8 /// click, and a corner is a place you can find again without looking.
9 public enum MiniWindowCorner: String, CaseIterable, Identifiable, Sendable {
10 case bottomRight, bottomLeft, topRight, topLeft
11
12 public var id: String { rawValue }
13
14 public var title: String {
15 switch self {
16 case .bottomRight: "Bottom right"
17 case .bottomLeft: "Bottom left"
18 case .topRight: "Top right"
19 case .topLeft: "Top left"
20 }
21 }
22
23 var right: Bool { self == .bottomRight || self == .topRight }
24 var top: Bool { self == .topRight || self == .topLeft }
25
26 /// The window's frame in this corner of `visible`, which is a screen's
27 /// `visibleFrame`: already clear of the menu bar and the Dock, so the
28 /// inset is only breathing room. AppKit's origin is bottom-left.
29 public func frame(size: CGSize, in visible: CGRect, inset: CGFloat = MiniWindowRules.inset)
30 -> CGRect
31 {
32 CGRect(
33 x: right ? visible.maxX - inset - size.width : visible.minX + inset,
34 y: top ? visible.maxY - inset - size.height : visible.minY + inset,
35 width: size.width, height: size.height)
36 }
37
38 /// The corner a dragged window belongs to: whichever side of the
39 /// screen's middle its own middle landed on.
40 public static func nearest(to frame: CGRect, in visible: CGRect) -> MiniWindowCorner {
41 let right = frame.midX >= visible.midX
42 let top = frame.midY >= visible.midY
43 switch (top, right) {
44 case (false, true): return .bottomRight
45 case (false, false): return .bottomLeft
46 case (true, true): return .topRight
47 case (true, false): return .topLeft
48 }
49 }
50 }
51
52 /// Whether a dragged mini window settles in a corner or stays where it
53 /// was dropped. Corners by default, for the reason MiniWindowCorner
54 /// gives; "anywhere" is for the person who knows which part of their
55 /// screen they never look at.
56 public enum MiniWindowPlacement: String, CaseIterable, Identifiable, Sendable {
57 case corners, anywhere
58
59 public var id: String { rawValue }
60
61 public var title: String {
62 switch self {
63 case .corners: "Snap to corners"
64 case .anywhere: "Anywhere I put it"
65 }
66 }
67 }
68
69 public enum MiniWindowRules {
70 /// 16:9 at the width of a small picture in picture: big enough to
71 /// read a face, small enough to leave the app you went to usable.
72 /// The size it opens at until someone resizes it.
73 public static let size = CGSize(width: 320, height: 180)
74 public static let inset: CGFloat = 20
75 public static let aspect: CGFloat = 16 / 9
76 /// Narrower than this and Mute, Leave and Collapse no longer fit
77 /// across the top.
78 public static let minimumWidth: CGFloat = 200
79 /// Wider than this share of the screen and it is no longer a mini
80 /// window, it is a second call window covering the app you went to.
81 public static let maximumShare: CGFloat = 0.4
82
83 /// The size for a width someone chose, held to 16:9 and to the limits
84 /// of the screen it is on now — which may be a smaller one than the
85 /// width was chosen on.
86 public static func size(width: CGFloat, in visible: CGRect) -> CGSize {
87 let widest = maximumSize(in: visible).width
88 let chosen = min(max(width.rounded(), minimumWidth), widest)
89 return CGSize(width: chosen, height: chosen / aspect)
90 }
91
92 public static func minimumSize() -> CGSize {
93 CGSize(width: minimumWidth, height: minimumWidth / aspect)
94 }
95
96 /// Never below the minimum, even on a screen too small for 40% of it
97 /// to be that wide.
98 public static func maximumSize(in visible: CGRect) -> CGSize {
99 let widest = max(minimumWidth, (visible.width * maximumShare).rounded(.down))
100 return CGSize(width: widest, height: widest / aspect)
101 }
102
103 /// `frame` moved the least distance that puts all of it inside
104 /// `visible`. Its size is kept; one bigger than the screen is pinned
105 /// to the top left, where the controls are.
106 public static func clamp(_ frame: CGRect, into visible: CGRect) -> CGRect {
107 var out = frame
108 out.origin.x = min(max(frame.minX, visible.minX), visible.maxX - frame.width)
109 out.origin.x = max(out.origin.x, visible.minX)
110 out.origin.y = max(min(frame.minY, visible.maxY - frame.height), visible.minY)
111 out.origin.y = min(out.origin.y, visible.maxY - frame.height)
112 return out
113 }
114
115 /// Where a freely placed window is, relative to its screen's visible
116 /// frame. Kept this way rather than in global coordinates so that
117 /// moving a display in System Settings → Displays → Arrange does not
118 /// throw the window off it.
119 public static func offset(of frame: CGRect, in visible: CGRect) -> CGPoint {
120 CGPoint(x: frame.minX - visible.minX, y: frame.minY - visible.minY)
121 }
122
123 /// A remembered offset put back on a screen, clamped: the display may
124 /// have changed resolution, or been swapped for a smaller one.
125 public static func frame(offset: CGPoint, size: CGSize, in visible: CGRect) -> CGRect {
126 clamp(
127 CGRect(
128 origin: CGPoint(x: visible.minX + offset.x, y: visible.minY + offset.y), size: size),
129 into: visible)
130 }
131 }
132
133 /// Resizing the mini window from its edges and corners, always at
134 /// 16:9. Done by the window's own surface rather than by AppKit's
135 /// `.resizable`, whose edge handling on a borderless panel is not
136 /// something to rely on, and which could not be held to this aspect
137 /// and these limits in one place that tests can reach.
138 public enum MiniWindowResize {
139 public struct Handle: OptionSet, Hashable, Sendable {
140 public let rawValue: Int
141 public init(rawValue: Int) { self.rawValue = rawValue }
142 public static let left = Handle(rawValue: 1)
143 public static let right = Handle(rawValue: 2)
144 public static let top = Handle(rawValue: 4)
145 public static let bottom = Handle(rawValue: 8)
146 }
147
148 /// How close to an edge, inside the window, a press starts a resize.
149 public static let grip: CGFloat = 8
150
151 /// The edges a press at `point` (in the window's own coordinates,
152 /// origin bottom-left) takes hold of. Two at a corner, none in the
153 /// middle, where a press is a click or a move.
154 public static func handle(at point: CGPoint, in size: CGSize) -> Handle {
155 var handle: Handle = []
156 if point.x <= grip { handle.insert(.left) }
157 if point.x >= size.width - grip { handle.insert(.right) }
158 if point.y <= grip { handle.insert(.bottom) }
159 if point.y >= size.height - grip { handle.insert(.top) }
160 return handle
161 }
162
163 /// The frame after dragging `handle` by (dx, dy) from `start`. The
164 /// opposite edge or corner stays put; an edge on its own grows the
165 /// window evenly either side of its middle. At a corner, whichever
166 /// direction asks for the bigger change wins, so the corner follows
167 /// the pointer as closely as 16:9 allows.
168 public static func frame(
169 from start: CGRect, dragging handle: Handle, dx: CGFloat, dy: CGFloat, in visible: CGRect
170 ) -> CGRect {
171 let horizontal: CGFloat? =
172 handle.contains(.right) ? start.width + dx : handle.contains(.left) ? start.width - dx : nil
173 let vertical: CGFloat? =
174 handle.contains(.top)
175 ? (start.height + dy) * MiniWindowRules.aspect
176 : handle.contains(.bottom) ? (start.height - dy) * MiniWindowRules.aspect : nil
177 let proposed: CGFloat
178 switch (horizontal, vertical) {
179 case (let h?, let v?): proposed = abs(h - start.width) >= abs(v - start.width) ? h : v
180 case (let h?, nil): proposed = h
181 case (nil, let v?): proposed = v
182 case (nil, nil): return start
183 }
184 let size = MiniWindowRules.size(width: proposed, in: visible)
185 let x: CGFloat =
186 handle.contains(.left)
187 ? start.maxX - size.width
188 : handle.contains(.right) ? start.minX : start.midX - size.width / 2
189 let y: CGFloat =
190 handle.contains(.bottom)
191 ? start.maxY - size.height
192 : handle.contains(.top) ? start.minY : start.midY - size.height / 2
193 return CGRect(origin: CGPoint(x: x, y: y), size: size)
194 }
195 }
196
197 /// The screen edge a collapsed mini window sits against.
198 public enum MiniWindowEdge: String, Sendable {
199 case left, right, top, bottom
200 }
201
202 /// The mini window collapsed to a tab: small, flush against the nearest
203 /// edge of the screen, out of the way of whatever the person went to
204 /// the other app for.
205 public enum MiniWindowTab {
206 public static let size = CGSize(width: 156, height: 40)
207 /// How far from its edge a dragged tab has to be let go to count as
208 /// pulled out, rather than slid along the edge to somewhere else.
209 public static let pullOut: CGFloat = 48
210
211 /// The edge `frame` is closest to. Ties go left and right before top
212 /// and bottom, because the corners are where a snapped window lives
213 /// and a tab beside it reads as belonging to the side it came from.
214 public static func nearestEdge(to frame: CGRect, in visible: CGRect) -> MiniWindowEdge {
215 let distances: [(MiniWindowEdge, CGFloat)] = [
216 (.right, visible.maxX - frame.maxX), (.left, frame.minX - visible.minX),
217 (.bottom, frame.minY - visible.minY), (.top, visible.maxY - frame.maxY),
218 ]
219 return distances.min { $0.1 < $1.1 }!.0
220 }
221
222 /// The tab for a window at `frame`: against its nearest edge, level
223 /// with the window's middle along that edge, and on screen.
224 public static func dock(_ frame: CGRect, in visible: CGRect) -> (MiniWindowEdge, CGRect) {
225 let edge = nearestEdge(to: frame, in: visible)
226 let x: CGFloat
227 let y: CGFloat
228 switch edge {
229 case .left: (x, y) = (visible.minX, frame.midY - size.height / 2)
230 case .right: (x, y) = (visible.maxX - size.width, frame.midY - size.height / 2)
231 case .bottom: (x, y) = (frame.midX - size.width / 2, visible.minY)
232 case .top: (x, y) = (frame.midX - size.width / 2, visible.maxY - size.height)
233 }
234 return (
235 edge,
236 MiniWindowRules.clamp(CGRect(origin: CGPoint(x: x, y: y), size: size), into: visible)
237 )
238 }
239
240 public enum Release: Equatable {
241 /// Back against an edge — the same one, or another it was slid to.
242 case dock(MiniWindowEdge, CGRect)
243 /// Pulled out: open the mini window again.
244 case expand
245 }
246
247 /// What letting go of a dragged tab means.
248 public static func release(_ frame: CGRect, in visible: CGRect) -> Release {
249 let away = min(
250 frame.minX - visible.minX, visible.maxX - frame.maxX,
251 frame.minY - visible.minY, visible.maxY - frame.maxY)
252 if away > pullOut { return .expand }
253 let (edge, docked) = dock(frame, in: visible)
254 return .dock(edge, docked)
255 }
256
257 /// A window of `size` opened from a tab: centred where the tab is,
258 /// and on screen.
259 public static func expanded(from tab: CGRect, size: CGSize, in visible: CGRect) -> CGRect {
260 MiniWindowRules.clamp(
261 CGRect(
262 x: tab.midX - size.width / 2, y: tab.midY - size.height / 2,
263 width: size.width, height: size.height),
264 into: visible)
265 }
266
267 /// Who the call is with, in the few words a tab has room for: the
268 /// other person's name when there is one, a count when there are
269 /// more, and "Just you" when nobody else has come.
270 public static func caption(others: [String]) -> String {
271 switch others.count {
272 case 0: "Just you"
273 case 1: others[0]
274 default: "\(others.count + 1) people"
275 }
276 }
277 }
278
279 /// The two preferences, read and written in one place so the Settings
280 /// window and the mini window cannot disagree about a key or a default.
281 ///
282 /// On by default, because the person who asked for it switches apps
283 /// during most calls, and a setting nobody knows about that is off is a
284 /// feature that does not exist.
285 public struct MiniWindowPreferences {
286 public static let enabledKey = "miniWindow.enabled"
287 public static let cornerKey = "miniWindow.corner"
288 public static let placementKey = "miniWindow.placement"
289 public static let widthKey = "miniWindow.width"
290 public static let positionsKey = "miniWindow.positions"
291 public static let defaultEnabled = true
292 public static let defaultCorner = MiniWindowCorner.bottomRight
293 public static let defaultPlacement = MiniWindowPlacement.corners
294
295 private let defaults: UserDefaults
296 public init(defaults: UserDefaults = .standard) { self.defaults = defaults }
297
298 public var enabled: Bool {
299 get { defaults.object(forKey: Self.enabledKey) as? Bool ?? Self.defaultEnabled }
300 nonmutating set { defaults.set(newValue, forKey: Self.enabledKey) }
301 }
302
303 public var corner: MiniWindowCorner {
304 get {
305 defaults.string(forKey: Self.cornerKey).flatMap(MiniWindowCorner.init(rawValue:))
306 ?? Self.defaultCorner
307 }
308 nonmutating set { defaults.set(newValue.rawValue, forKey: Self.cornerKey) }
309 }
310
311 public var placement: MiniWindowPlacement {
312 get {
313 defaults.string(forKey: Self.placementKey).flatMap(MiniWindowPlacement.init(rawValue:))
314 ?? Self.defaultPlacement
315 }
316 nonmutating set { defaults.set(newValue.rawValue, forKey: Self.placementKey) }
317 }
318
319 /// The width last resized to. Only the width: the height follows
320 /// from 16:9, so there is nothing to disagree about.
321 public var width: CGFloat {
322 get {
323 (defaults.object(forKey: Self.widthKey) as? Double).map { CGFloat($0) }
324 ?? MiniWindowRules.size.width
325 }
326 nonmutating set { defaults.set(Double(newValue), forKey: Self.widthKey) }
327 }
328
329 /// Where the window was left in "anywhere" mode, per display, as an
330 /// offset from that display's visible frame (see
331 /// MiniWindowRules.offset). Keyed by the display's ID as a string, and
332 /// kept as one JSON string so that Settings can clear it through
333 /// AppStorage.
334 public func position(on display: String) -> CGPoint? {
335 guard let pair = positions[display], pair.count == 2 else { return nil }
336 return CGPoint(x: pair[0], y: pair[1])
337 }
338
339 public func setPosition(_ offset: CGPoint, on display: String) {
340 var all = positions
341 all[display] = [Double(offset.x), Double(offset.y)]
342 guard let data = try? JSONEncoder().encode(all) else { return }
343 defaults.set(String(decoding: data, as: UTF8.self), forKey: Self.positionsKey)
344 }
345
346 private var positions: [String: [Double]] {
347 guard let text = defaults.string(forKey: Self.positionsKey),
348 let all = try? JSONDecoder().decode([String: [Double]].self, from: Data(text.utf8))
349 else { return [:] }
350 return all
351 }
352
353 public func forgetPositions() { defaults.removeObject(forKey: Self.positionsKey) }
354 }
355
356 /// What the mini window remembers for one call and forgets when it
357 /// ends: whether it was collapsed to a tab, and any position it was
358 /// ⌥-dragged to while the setting is "Snap to corners". Every call
359 /// starts expanded — a tab left over from yesterday's call would be a
360 /// call nobody can see they are in.
361 public struct MiniWindowCallMemory: Equatable {
362 public var collapsed = false
363 /// ⌥-dropped positions by display, as offsets. One-offs: they win
364 /// over the corner for the rest of this call and are never saved.
365 public private(set) var oneOff: [String: CGPoint] = [:]
366
367 public init() {}
368
369 /// Where a newly shown window of `size` goes on `display`.
370 public func frame(
371 size: CGSize, in visible: CGRect, display: String, preferences: MiniWindowPreferences
372 ) -> CGRect {
373 if let offset = oneOff[display] {
374 return MiniWindowRules.frame(offset: offset, size: size, in: visible)
375 }
376 if preferences.placement == .anywhere, let offset = preferences.position(on: display) {
377 return MiniWindowRules.frame(offset: offset, size: size, in: visible)
378 }
379 return preferences.corner.frame(size: size, in: visible)
380 }
381
382 /// A drag ended with the window at `frame` on `display`. Returns
383 /// where it settles.
384 ///
385 /// - "Anywhere I put it": exactly there (clamped on screen), saved for
386 /// that display.
387 /// - "Snap to corners" with ⌥ held: exactly there, for this call only.
388 /// - "Snap to corners": the nearest corner, which becomes the setting,
389 /// and any one-off on that display is dropped.
390 public mutating func dropped(
391 _ frame: CGRect, in visible: CGRect, display: String, option: Bool,
392 preferences: MiniWindowPreferences
393 ) -> CGRect {
394 let placed = MiniWindowRules.clamp(frame, into: visible)
395 switch (preferences.placement, option) {
396 case (.anywhere, _):
397 preferences.setPosition(MiniWindowRules.offset(of: placed, in: visible), on: display)
398 return placed
399 case (.corners, true):
400 oneOff[display] = MiniWindowRules.offset(of: placed, in: visible)
401 return placed
402 case (.corners, false):
403 oneOff[display] = nil
404 let corner = MiniWindowCorner.nearest(to: frame, in: visible)
405 preferences.corner = corner
406 return corner.frame(size: frame.size, in: visible)
407 }
408 }
409
410 /// A resize ended with the window at `frame`. It settles as a drop
411 /// would, except that a window ⌥-placed for this call stays placed
412 /// rather than snapping: resizing it is not asking for a corner.
413 public mutating func resized(
414 _ frame: CGRect, in visible: CGRect, display: String, preferences: MiniWindowPreferences
415 ) -> CGRect {
416 dropped(
417 frame, in: visible, display: display, option: oneOff[display] != nil,
418 preferences: preferences)
419 }
420 }
421
422 /// When the mini window comes and goes. Kept apart from the windows it
423 /// moves, like KnockWatch, because this is the part with rules in it and
424 /// the windows need a real app to exist.
425 ///
426 /// The rules:
427 /// - It appears only when the app stops being frontmost during a call,
428 /// with the setting on and the call window on screen. A minimised or
429 /// closed call window is one the person already put away themselves.
430 /// - ⌘H wins. Hiding the app is asking for all of it to go, so a hidden
431 /// app shows nothing, and a mini window already up is put away before
432 /// the hide so that unhiding brings back the ordinary window. AppKit's
433 /// order between "will hide" and "resigned active" is not something to
434 /// rely on, so both orders end the same way.
435 /// - Coming back to the app, by any route, restores the call window.
436 /// - The call ending puts it away too — someone who pressed Leave in the
437 /// mini window is done, and the lobby is waiting for them underneath.
438 public struct MiniWindowPolicy: Equatable {
439 public struct Conditions: Equatable {
440 public var enabled: Bool
441 public var inCall: Bool
442 public var mainWindowVisible: Bool
443 public var mainWindowMiniaturized: Bool
444 public var mainWindowFullScreen: Bool
445
446 public init(
447 enabled: Bool, inCall: Bool, mainWindowVisible: Bool,
448 mainWindowMiniaturized: Bool = false, mainWindowFullScreen: Bool = false
449 ) {
450 self.enabled = enabled
451 self.inCall = inCall
452 self.mainWindowVisible = mainWindowVisible
453 self.mainWindowMiniaturized = mainWindowMiniaturized
454 self.mainWindowFullScreen = mainWindowFullScreen
455 }
456 }
457
458 public enum Action: Equatable {
459 case none
460 /// Show the mini window. `hideMain` is false for a full-screen call
461 /// window: hiding one leaves its Space black and empty, and it is
462 /// already out of the way in a Space of its own.
463 case show(hideMain: Bool)
464 /// Close the mini window and put the call window back as it was.
465 /// `activate` is false when the app is not coming forward — the call
466 /// ended from the mini window, or the app is being hidden — so the
467 /// window returns where it was in the stack, behind whatever the
468 /// person is doing, rather than jumping on top of it.
469 case restore(activate: Bool)
470 }
471
472 public private(set) var showing = false
473 /// Whether the main window was hidden, as opposed to left alone in
474 /// its full-screen Space.
475 public private(set) var mainHidden = false
476 public private(set) var appHidden = false
477
478 public init() {}
479
480 public mutating func resignedActive(_ conditions: Conditions) -> Action {
481 guard !showing, !appHidden, conditions.enabled, conditions.inCall,
482 conditions.mainWindowVisible, !conditions.mainWindowMiniaturized
483 else { return .none }
484 showing = true
485 mainHidden = !conditions.mainWindowFullScreen
486 return .show(hideMain: mainHidden)
487 }
488
489 public mutating func becameActive() -> Action {
490 appHidden = false
491 return restore(activate: true)
492 }
493
494 public mutating func willHide() -> Action {
495 appHidden = true
496 return restore(activate: false)
497 }
498
499 public mutating func didUnhide() { appHidden = false }
500
501 public mutating func callEnded() -> Action { restore(activate: false) }
502
503 private mutating func restore(activate: Bool) -> Action {
504 guard showing else { return .none }
505 showing = false
506 mainHidden = false
507 return .restore(activate: activate)
508 }
509 }
510
511/// What one press on the mini window's surface is (2026-10-05, Paul:
512/// "Sometimes I can't click the mini window to either bring the call to
513/// the front or move it around"): apart from any view, so tests hold it.
514/// A press that ends within `slack` of where it began is a click, which
515/// brings the call forward (the tab: opens the window; twice, the call).
516/// One that travels further is a drag: from an edge or corner of the
517/// expanded window it resizes, from anywhere else it moves. The tab has
518/// no edges to resize by. Controls are SwiftUI buttons over the surface
519/// and never reach it.
520public enum MiniWindowGesture {
521 public enum Surface: String, Sendable { case window, tab }
522 public enum Outcome: Equatable, Sendable {
523 case click(count: Int)
524 case move(option: Bool)
525 case resize(MiniWindowResize.Handle)
526 }
527
528 /// How far a press may wander and still be a click: a shaky hand's.
529 public static let slack: CGFloat = 4
530
531 /// The edges a press at `point` takes hold of: none on the tab, and none
532 /// on a window too small for its grips to leave a middle to move by.
533 public static func grab(at point: CGPoint, in size: CGSize, surface: Surface) -> MiniWindowResize.Handle {
534 guard surface == .window, size.width > 4 * MiniWindowResize.grip,
535 size.height > 4 * MiniWindowResize.grip
536 else { return [] }
537 return MiniWindowResize.handle(at: point, in: size)
538 }
539
540 public static func dragged(dx: CGFloat, dy: CGFloat) -> Bool { hypot(dx, dy) > slack }
541
542 /// The press's outcome once the button is up.
543 public static func outcome(
544 grabbed: MiniWindowResize.Handle, dragged: Bool, clickCount: Int, option: Bool
545 ) -> Outcome {
546 guard dragged else { return .click(count: max(1, clickCount)) }
547 return grabbed.isEmpty ? .move(option: option) : .resize(grabbed)
548 }
549}
550#endif
551