Skip to content

Commit 23baa8e

Browse files
committed
Tweak collection behavior when Xcode is in fullscreen
1 parent 71db7c4 commit 23baa8e

4 files changed

Lines changed: 142 additions & 40 deletions

File tree

Core/Sources/SuggestionWidget/ChatPanelWindow.swift

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,42 @@ import ComposableArchitecture
44
import Foundation
55
import SwiftUI
66

7-
final class ChatPanelWindow: NSWindow {
7+
final class ChatPanelWindow: WidgetWindow {
88
override var canBecomeKey: Bool { true }
99
override var canBecomeMain: Bool { true }
1010

1111
private let storeObserver = NSObject()
1212

1313
var minimizeWindow: () -> Void = {}
1414

15+
override var defaultCollectionBehavior: NSWindow.CollectionBehavior {
16+
[
17+
.fullScreenAuxiliary,
18+
.transient,
19+
.fullScreenPrimary,
20+
.fullScreenAllowsTiling,
21+
]
22+
}
23+
24+
override var switchingSpaceCollectionBehavior: NSWindow.CollectionBehavior {
25+
[
26+
.fullScreenAuxiliary,
27+
.transient,
28+
.fullScreenPrimary,
29+
.fullScreenAllowsTiling,
30+
]
31+
}
32+
33+
override var fullscreenCollectionBehavior: NSWindow.CollectionBehavior {
34+
[
35+
.fullScreenAuxiliary,
36+
.transient,
37+
.fullScreenPrimary,
38+
.fullScreenAllowsTiling,
39+
.canJoinAllSpaces,
40+
]
41+
}
42+
1543
init(
1644
store: StoreOf<ChatPanelFeature>,
1745
chatTabPool: ChatTabPool,
@@ -39,13 +67,7 @@ final class ChatPanelWindow: NSWindow {
3967
isOpaque = false
4068
backgroundColor = .clear
4169
level = widgetLevel(1)
42-
collectionBehavior = [
43-
.fullScreenAuxiliary,
44-
.transient,
45-
.fullScreenPrimary,
46-
.fullScreenAllowsTiling,
47-
.canJoinAllSpaces
48-
]
70+
4971
hasShadow = true
5072
contentView = NSHostingView(
5173
rootView: ChatWindowView(

Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ public struct WidgetFeature {
8585
private enum CancelID {
8686
case observeActiveApplicationChange
8787
case observeCompletionPanelChange
88-
case observeFullscreenChange
8988
case observeWindowChange
9089
case observeEditorChange
9190
case observeUserDefaults
@@ -94,7 +93,6 @@ public struct WidgetFeature {
9493
public enum Action: Equatable {
9594
case startup
9695
case observeActiveApplicationChange
97-
case observeFullscreenChange
9896
case observeColorSchemeChange
9997

10098
case updateActiveApplication
@@ -227,7 +225,6 @@ public struct WidgetFeature {
227225
.run { send in
228226
await send(.toastPanel(.start))
229227
await send(.observeActiveApplicationChange)
230-
await send(.observeFullscreenChange)
231228
await send(.observeColorSchemeChange)
232229
}
233230
)
@@ -254,24 +251,6 @@ public struct WidgetFeature {
254251
}
255252
}.cancellable(id: CancelID.observeActiveApplicationChange, cancelInFlight: true)
256253

257-
case .observeFullscreenChange:
258-
return .run { _ in
259-
let sequence = NSWorkspace.shared.notificationCenter
260-
.notifications(named: NSWorkspace.activeSpaceDidChangeNotification)
261-
for await _ in sequence {
262-
try Task.checkCancellation()
263-
guard let activeXcode = await xcodeInspector.safe.activeXcode
264-
else { continue }
265-
guard let windowsController,
266-
await windowsController.windows.fullscreenDetector.isOnActiveSpace
267-
else { continue }
268-
let app = activeXcode.appElement
269-
if let _ = app.focusedWindow {
270-
await windowsController.windows.orderFront()
271-
}
272-
}
273-
}.cancellable(id: CancelID.observeFullscreenChange, cancelInFlight: true)
274-
275254
case .observeColorSchemeChange:
276255
return .run { send in
277256
await send(.updateColorScheme)

Core/Sources/SuggestionWidget/WidgetWindowsController.swift

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ actor WidgetWindowsController: NSObject {
2929
var lastUpdateWindowLocationTime = Date(timeIntervalSince1970: 0)
3030

3131
var beatingCompletionPanelTask: Task<Void, Error>?
32+
var updateWindowStateTask: Task<Void, Error>?
3233

3334
deinit {
3435
userDefaultsObservers.presentationModeChangeObserver.onChange = {}
3536
observeToAppTask?.cancel()
3637
observeToFocusedEditorTask?.cancel()
38+
updateWindowStateTask?.cancel()
3739
}
3840

3941
init(store: StoreOf<WidgetFeature>, chatTabPool: ChatTabPool) {
@@ -73,6 +75,23 @@ actor WidgetWindowsController: NSObject {
7375
await self?.send(.updateColorScheme)
7476
}
7577
}
78+
79+
updateWindowStateTask = Task { [weak self] in
80+
if let self { await handleXcodeFullscreenChange() }
81+
82+
await withThrowingTaskGroup(of: Void.self) { [weak self] group in
83+
// active space did change
84+
_ = group.addTaskUnlessCancelled { [weak self] in
85+
let sequence = NSWorkspace.shared.notificationCenter
86+
.notifications(named: NSWorkspace.activeSpaceDidChangeNotification)
87+
for await _ in sequence {
88+
guard let self else { return }
89+
try Task.checkCancellation()
90+
await handleXcodeFullscreenChange()
91+
}
92+
}
93+
}
94+
}
7695
}
7796
}
7897

@@ -135,7 +154,11 @@ private extension WidgetWindowsController {
135154
}
136155

137156
switch notification.kind {
138-
case .focusedWindowChanged, .focusedUIElementChanged:
157+
case .focusedWindowChanged:
158+
await handleXcodeFullscreenChange()
159+
await hideWidgetForTransitions()
160+
await updateWidgetsAndNotifyChangeOfEditor(immediately: true)
161+
case .focusedUIElementChanged:
139162
await hideWidgetForTransitions()
140163
await updateWidgetsAndNotifyChangeOfEditor(immediately: true)
141164
case .applicationActivated:
@@ -547,6 +570,35 @@ extension WidgetWindowsController {
547570
window.setFloatOnTop(overlap)
548571
}
549572
}
573+
574+
@MainActor
575+
func handleXcodeFullscreenChange() async {
576+
guard let activeXcode = await XcodeInspector.shared.safe.activeXcode
577+
else { return }
578+
579+
let xcode = activeXcode.appElement
580+
let isFullscreen = if let xcodeWindow = xcode.focusedWindow {
581+
xcodeWindow.isFullScreen && xcode.isFrontmost
582+
} else {
583+
false
584+
}
585+
586+
[
587+
windows.chatPanelWindow,
588+
windows.sharedPanelWindow,
589+
windows.suggestionPanelWindow,
590+
windows.widgetWindow,
591+
windows.toastWindow,
592+
].forEach {
593+
$0.send(.didChangeActiveSpace(fullscreen: isFullscreen))
594+
}
595+
596+
if windows.fullscreenDetector.isOnActiveSpace {
597+
if xcode.focusedWindow != nil {
598+
windows.orderFront()
599+
}
600+
}
601+
}
550602
}
551603

552604
// MARK: - NSWindowDelegate
@@ -623,7 +675,7 @@ public final class WidgetWindows {
623675

624676
@MainActor
625677
lazy var widgetWindow = {
626-
let it = CanBecomeKeyWindow(
678+
let it = WidgetWindow(
627679
contentRect: .zero,
628680
styleMask: .borderless,
629681
backing: .buffered,
@@ -632,8 +684,7 @@ public final class WidgetWindows {
632684
it.isReleasedWhenClosed = false
633685
it.isOpaque = false
634686
it.backgroundColor = .clear
635-
it.level = .floating
636-
it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
687+
it.level = widgetLevel(0)
637688
it.hasShadow = true
638689
it.contentView = NSHostingView(
639690
rootView: WidgetView(
@@ -650,7 +701,7 @@ public final class WidgetWindows {
650701

651702
@MainActor
652703
lazy var sharedPanelWindow = {
653-
let it = CanBecomeKeyWindow(
704+
let it = WidgetWindow(
654705
contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight),
655706
styleMask: .borderless,
656707
backing: .buffered,
@@ -660,7 +711,6 @@ public final class WidgetWindows {
660711
it.isOpaque = false
661712
it.backgroundColor = .clear
662713
it.level = widgetLevel(2)
663-
it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
664714
it.hasShadow = true
665715
it.contentView = NSHostingView(
666716
rootView: SharedPanelView(
@@ -684,7 +734,7 @@ public final class WidgetWindows {
684734

685735
@MainActor
686736
lazy var suggestionPanelWindow = {
687-
let it = CanBecomeKeyWindow(
737+
let it = WidgetWindow(
688738
contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight),
689739
styleMask: .borderless,
690740
backing: .buffered,
@@ -694,7 +744,6 @@ public final class WidgetWindows {
694744
it.isOpaque = false
695745
it.backgroundColor = .clear
696746
it.level = widgetLevel(2)
697-
it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
698747
it.hasShadow = true
699748
it.contentView = NSHostingView(
700749
rootView: SuggestionPanelView(
@@ -730,7 +779,7 @@ public final class WidgetWindows {
730779

731780
@MainActor
732781
lazy var toastWindow = {
733-
let it = CanBecomeKeyWindow(
782+
let it = WidgetWindow(
734783
contentRect: .zero,
735784
styleMask: [.borderless],
736785
backing: .buffered,
@@ -740,7 +789,6 @@ public final class WidgetWindows {
740789
it.isOpaque = true
741790
it.backgroundColor = .clear
742791
it.level = widgetLevel(0)
743-
it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
744792
it.hasShadow = false
745793
it.contentView = NSHostingView(
746794
rootView: ToastPanelView(store: store.scope(
@@ -782,9 +830,58 @@ class CanBecomeKeyWindow: NSWindow {
782830
override var canBecomeMain: Bool { canBecomeKeyChecker() }
783831
}
784832

833+
class WidgetWindow: CanBecomeKeyWindow {
834+
enum State: Equatable {
835+
case normal(fullscreen: Bool)
836+
case switchingSpace
837+
}
838+
839+
enum Action {
840+
case didChangeActiveSpace(fullscreen: Bool)
841+
}
842+
843+
var defaultCollectionBehavior: NSWindow.CollectionBehavior {
844+
[.fullScreenAuxiliary, .transient]
845+
}
846+
847+
var fullscreenCollectionBehavior: NSWindow.CollectionBehavior {
848+
// .canJoinAllSpaces is required for macOS 15 (beta?) to display widgets in fullscreen mode.
849+
// But adding this behavior will create another issue that the widgets will display
850+
// whenever user switch spaces, so we are setting it only when the window is in fullscreen
851+
// mode.
852+
[.fullScreenAuxiliary, .transient, .canJoinAllSpaces]
853+
}
854+
855+
var switchingSpaceCollectionBehavior: NSWindow.CollectionBehavior {
856+
[.fullScreenAuxiliary, .transient]
857+
}
858+
859+
private var state: State? {
860+
didSet {
861+
guard state != oldValue else { return }
862+
switch state {
863+
case .none:
864+
collectionBehavior = defaultCollectionBehavior
865+
case .switchingSpace:
866+
collectionBehavior = switchingSpaceCollectionBehavior
867+
case let .normal(fullscreen):
868+
collectionBehavior = fullscreen
869+
? fullscreenCollectionBehavior
870+
: defaultCollectionBehavior
871+
}
872+
}
873+
}
874+
875+
func send(_ action: Action) {
876+
switch action {
877+
case let .didChangeActiveSpace(fullscreen):
878+
state = .normal(fullscreen: fullscreen)
879+
}
880+
}
881+
}
882+
785883
func widgetLevel(_ addition: Int) -> NSWindow.Level {
786884
let minimumWidgetLevel: Int
787885
minimumWidgetLevel = NSWindow.Level.floating.rawValue
788886
return .init(minimumWidgetLevel + addition)
789887
}
790-

Tool/Sources/AXExtension/AXUIElement.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ public extension AXUIElement {
134134
var isFullScreen: Bool {
135135
(try? copyValue(key: "AXFullScreen")) ?? false
136136
}
137+
138+
var isFrontmost: Bool {
139+
(try? copyValue(key: kAXFrontmostAttribute)) ?? false
140+
}
137141

138142
var focusedWindow: AXUIElement? {
139143
try? copyValue(key: kAXFocusedWindowAttribute)

0 commit comments

Comments
 (0)