| 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. |
| 520 | public 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 | |