blob: ca41bf89c8ee30a4e2f8e8143196124fa6aaf8aa (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
|
import AppKit
/// Coordinates close operations for windows that are part of a tab group.
///
/// This coordinator helps distinguish between closing a single tab versus closing
/// an entire window (with all its tabs). When macOS native tabs are used, close
/// operations can be ambiguous - this coordinator tracks close requests across
/// multiple windows in a tab group to determine the user's intent.
class TabGroupCloseCoordinator {
/// The scope of a close operation.
enum CloseScope {
case tab
case window
}
/// Protocol that window controllers must implement to use the coordinator.
protocol Controller {
/// The tab group close coordinator instance for this controller.
var tabGroupCloseCoordinator: TabGroupCloseCoordinator { get }
}
/// Callback type for close operations.
typealias Callback = (CloseScope) -> Void
// We use weak vars and ObjectIdentifiers below because we don't want to
// create any strong reference cycles during coordination.
/// The tab group being coordinated. Weak reference to avoid cycles.
private weak var tabGroup: NSWindowTabGroup?
/// Map of window identifiers to their close callbacks.
private var closeRequests: [ObjectIdentifier: Callback] = [:]
/// Timer used to debounce close requests and determine intent.
private var debounceTimer: Timer?
deinit {
trigger(.tab)
}
/// Call this from the windowShouldClose override in order to track whether
/// a window close event is from a tab or a window. If this window already
/// requested a close then only the latest will be called.
func windowShouldClose(
_ window: NSWindow,
callback: @escaping Callback
) {
// If this window isn't part of a tab group we assume its a window
// close for the window and let our timer keep running for the rest.
guard let tabGroup = window.tabGroup else {
callback(.window)
return
}
// Forward to the proper coordinator
if let firstController = tabGroup.windows.first?.windowController as? Controller,
firstController.tabGroupCloseCoordinator !== self {
let coordinator = firstController.tabGroupCloseCoordinator
coordinator.windowShouldClose(window, callback: callback)
return
}
// If our tab group is nil then we either are seeing this for the first
// time or our weak ref expired and we should fire our callbacks.
if self.tabGroup == nil {
self.tabGroup = tabGroup
debounceTimer?.fire()
debounceTimer = nil
}
// No matter what, we cancel our debounce and restart this. This opens
// us up to a DoS if close requests are looped but this would only
// happen in hostile scenarios that are self-inflicted.
debounceTimer?.invalidate()
debounceTimer = nil
// If this tab group doesn't match then I don't really know what to
// do. This shouldn't happen. So we just assume it's a tab close
// and trigger the rest. No right answer here as far as I know.
if self.tabGroup != tabGroup {
callback(.tab)
trigger(.tab)
return
}
// Add the request
closeRequests[ObjectIdentifier(window)] = callback
// If close requests matches all our windows then we are done.
if closeRequests.count == tabGroup.windows.count {
let allWindows = Set(tabGroup.windows.map { ObjectIdentifier($0) })
if Set(closeRequests.keys) == allWindows {
trigger(.window)
return
}
}
// Setup our new timer
debounceTimer = Timer.scheduledTimer(
withTimeInterval: Duration.milliseconds(100).timeInterval,
repeats: false
) { [weak self] _ in
self?.trigger(.tab)
}
}
/// Triggers all pending close callbacks with the given scope.
///
/// This method is called when the coordinator has determined the user's intent
/// (either closing a tab or the entire window). It executes all pending callbacks
/// and resets the coordinator's state.
///
/// - Parameter scope: The determined scope of the close operation.
private func trigger(_ scope: CloseScope) {
// Reset our state
tabGroup = nil
debounceTimer?.invalidate()
debounceTimer = nil
// Trigger all of our callbacks
closeRequests.forEach { $0.value(scope) }
closeRequests = [:]
}
}
|