Skip to content

Commit 2c28c5c

Browse files
committed
Automatic detach window on drag
1 parent f8ada0d commit 2c28c5c

5 files changed

Lines changed: 146 additions & 91 deletions

File tree

Core/Sources/SuggestionWidget/ChatWindowView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ private let r: Double = 8
77
final class ChatWindowViewModel: ObservableObject {
88
@Published var chat: ChatProvider?
99
@Published var colorScheme: ColorScheme
10+
@Published var isPanelDisplayed = false
11+
@AppStorage(\.chatPanelInASeparateWindow) var chatPanelInASeparateWindow
1012

1113
public init(chat: ChatProvider? = nil, colorScheme: ColorScheme = .dark) {
1214
self.chat = chat
@@ -23,6 +25,7 @@ struct ChatWindowView: View {
2325
ChatPanel(chat: chat)
2426
}
2527
}
28+
.opacity(viewModel.isPanelDisplayed ? 1 : 0)
2629
.frame(minWidth: Style.panelWidth, minHeight: Style.panelHeight)
2730
.preferredColorScheme(viewModel.colorScheme)
2831
}

Core/Sources/SuggestionWidget/SuggestionPanelView.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,14 @@ struct SuggestionPanelView: View {
131131
}
132132
}
133133

134-
if let chat = viewModel.chat {
135-
if case .chat = viewModel.activeTab {
136-
ChatPanel(chat: chat)
137-
.frame(maxWidth: .infinity, maxHeight: Style.panelHeight)
138-
.fixedSize(horizontal: false, vertical: true)
139-
.allowsHitTesting(viewModel.isPanelDisplayed)
140-
}
141-
}
134+
// if let chat = viewModel.chat {
135+
// if case .chat = viewModel.activeTab {
136+
// ChatPanel(chat: chat)
137+
// .frame(maxWidth: .infinity, maxHeight: Style.panelHeight)
138+
// .fixedSize(horizontal: false, vertical: true)
139+
// .allowsHitTesting(viewModel.isPanelDisplayed)
140+
// }
141+
// }
142142
}
143143
.frame(maxWidth: .infinity)
144144

Core/Sources/SuggestionWidget/SuggestionWidgetController.swift

Lines changed: 111 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Preferences
77
import SwiftUI
88

99
@MainActor
10-
public final class SuggestionWidgetController {
10+
public final class SuggestionWidgetController: NSObject {
1111
class UserDefaultsObserver: NSObject {
1212
var onChange: (() -> Void)?
1313

@@ -22,7 +22,7 @@ public final class SuggestionWidgetController {
2222
}
2323

2424
private lazy var widgetWindow = {
25-
let it = NSWindow(
25+
let it = CanBecomeKeyWindow(
2626
contentRect: .zero,
2727
styleMask: .borderless,
2828
backing: .buffered,
@@ -37,6 +37,7 @@ public final class SuggestionWidgetController {
3737
rootView: WidgetView(
3838
viewModel: widgetViewModel,
3939
panelViewModel: suggestionPanelViewModel,
40+
chatWindowViewModel: chatWindowViewModel,
4041
onOpenChatClicked: { [weak self] in
4142
self?.onOpenChatClicked()
4243
},
@@ -46,11 +47,12 @@ public final class SuggestionWidgetController {
4647
)
4748
)
4849
it.setIsVisible(true)
50+
it.canBecomeKeyChecker = { false }
4951
return it
5052
}()
5153

5254
private lazy var tabWindow = {
53-
let it = NSWindow(
55+
let it = CanBecomeKeyWindow(
5456
contentRect: .zero,
5557
styleMask: .borderless,
5658
backing: .buffered,
@@ -62,9 +64,10 @@ public final class SuggestionWidgetController {
6264
it.level = .floating
6365
it.hasShadow = true
6466
it.contentView = NSHostingView(
65-
rootView: TabView(panelViewModel: suggestionPanelViewModel)
67+
rootView: TabView(chatWindowViewModel: chatWindowViewModel)
6668
)
6769
it.setIsVisible(true)
70+
it.canBecomeKeyChecker = { false }
6871
return it
6972
}()
7073

@@ -85,15 +88,14 @@ public final class SuggestionWidgetController {
8588
)
8689
it.setIsVisible(true)
8790
it.canBecomeKeyChecker = { [suggestionPanelViewModel] in
88-
if case .chat = suggestionPanelViewModel.activeTab { return true }
8991
if case .promptToCode = suggestionPanelViewModel.content { return true }
9092
return false
9193
}
9294
return it
9395
}()
9496

9597
private lazy var chatWindow = {
96-
let it = CanBecomeKeyWindow(
98+
let it = ChatWindow(
9799
contentRect: .zero,
98100
styleMask: [.borderless, .resizable],
99101
backing: .buffered,
@@ -108,7 +110,7 @@ public final class SuggestionWidgetController {
108110
rootView: ChatWindowView(viewModel: chatWindowViewModel)
109111
)
110112
it.setIsVisible(true)
111-
it.canBecomeKeyChecker = { true }
113+
it.delegate = self
112114
return it
113115
}()
114116

@@ -118,6 +120,7 @@ public final class SuggestionWidgetController {
118120

119121
private var presentationModeChangeObserver = UserDefaultsObserver()
120122
private var colorSchemeChangeObserver = UserDefaultsObserver()
123+
private var detachChatPanelObserver = UserDefaultsObserver()
121124
private var windowChangeObservationTask: Task<Void, Error>?
122125
private var activeApplicationMonitorTask: Task<Void, Error>?
123126
private var sourceEditorMonitorTask: Task<Void, Error>?
@@ -128,7 +131,8 @@ public final class SuggestionWidgetController {
128131
public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in }
129132
public var dataSource: SuggestionWidgetDataSource?
130133

131-
public nonisolated init() {
134+
override public nonisolated init() {
135+
super.init()
132136
#warning(
133137
"TODO: A test is initializing this class for unknown reasons, try a better way to avoid this."
134138
)
@@ -156,6 +160,9 @@ public final class SuggestionWidgetController {
156160
self.widgetWindow.alphaValue = 0
157161
self.panelWindow.alphaValue = 0
158162
self.tabWindow.alphaValue = 0
163+
if !UserDefaults.shared.value(for: \.chatPanelInASeparateWindow) {
164+
self.chatWindow.alphaValue = 0
165+
}
159166
}
160167
}
161168
}
@@ -176,6 +183,19 @@ public final class SuggestionWidgetController {
176183
)
177184
}
178185

186+
Task { @MainActor in
187+
detachChatPanelObserver.onChange = { [weak self] in
188+
guard let self else { return }
189+
self.updateWindowLocation(animated: true)
190+
}
191+
UserDefaults.shared.addObserver(
192+
detachChatPanelObserver,
193+
forKeyPath: UserDefaultPreferenceKeys().chatPanelInASeparateWindow.key,
194+
options: .new,
195+
context: nil
196+
)
197+
}
198+
179199
Task { @MainActor in
180200
let updateColorScheme = { @MainActor [weak self] in
181201
guard let self else { return }
@@ -194,6 +214,7 @@ public final class SuggestionWidgetController {
194214
}
195215
}()
196216
self.suggestionPanelViewModel.colorScheme = self.colorScheme
217+
self.chatWindowViewModel.colorScheme = self.colorScheme
197218
Task {
198219
await self.updateContentForActiveEditor()
199220
}
@@ -218,33 +239,6 @@ public final class SuggestionWidgetController {
218239
context: nil
219240
)
220241
}
221-
222-
// Task { @MainActor in
223-
// var switchTask: Task<Void, Error>?
224-
// suggestionPanelViewModel.requestApplicationPolicyUpdate = { viewModel in
225-
// #warning("""
226-
// TODO: There should be a better way for that
227-
// Currently, we have to make the app an accessory so that we can type things in the
228-
// chat mode.
229-
// But in other modes, we want to keep it prohibited so the helper app won't take
230-
// over the focus.
231-
// """)
232-
// switch (viewModel.activeTab, viewModel.content) {
233-
// case (.chat, _), (.suggestion, .promptToCode):
234-
// guard NSApp.activationPolicy() != .accessory else { return }
235-
// switchTask?.cancel()
236-
// NSApp.setActivationPolicy(.accessory)
237-
// case (.suggestion, _):
238-
// guard NSApp.activationPolicy() != .prohibited else { return }
239-
// switchTask?.cancel()
240-
// switchTask = Task {
241-
// try await Environment.makeXcodeActive()
242-
// try Task.checkCancellation()
243-
// NSApp.setActivationPolicy(.prohibited)
244-
// }
245-
// }
246-
// }
247-
// }
248242
}
249243
}
250244

@@ -256,6 +250,7 @@ public extension SuggestionWidgetController {
256250
Task {
257251
if let suggestion = await dataSource?.suggestionForFile(at: fileURL) {
258252
suggestionPanelViewModel.content = .suggestion(suggestion)
253+
chatWindowViewModel.isPanelDisplayed = true
259254
suggestionPanelViewModel.isPanelDisplayed = true
260255
}
261256
}
@@ -274,6 +269,7 @@ public extension SuggestionWidgetController {
274269

275270
func presentError(_ errorDescription: String) {
276271
suggestionPanelViewModel.content = .error(errorDescription)
272+
chatWindowViewModel.isPanelDisplayed = true
277273
suggestionPanelViewModel.isPanelDisplayed = true
278274
widgetViewModel.isProcessing = false
279275
}
@@ -283,8 +279,9 @@ public extension SuggestionWidgetController {
283279
Task {
284280
if let chat = await dataSource?.chatForFile(at: fileURL) {
285281
chatWindowViewModel.chat = chat
286-
suggestionPanelViewModel.chat = chat
282+
chatWindowViewModel.isPanelDisplayed = true
287283
suggestionPanelViewModel.isPanelDisplayed = true
284+
suggestionPanelViewModel.chat = chat
288285

289286
if UserDefaults.shared.value(for: \.chatPanelInASeparateWindow) {
290287
self.updateWindowLocation()
@@ -311,6 +308,7 @@ public extension SuggestionWidgetController {
311308
Task {
312309
if let provider = await dataSource?.promptToCodeForFile(at: fileURL) {
313310
suggestionPanelViewModel.content = .promptToCode(provider)
311+
chatWindowViewModel.isPanelDisplayed = true
314312
suggestionPanelViewModel.isPanelDisplayed = true
315313

316314
Task { @MainActor in
@@ -449,14 +447,19 @@ extension SuggestionWidgetController {
449447
if tabWindow.alphaValue != 0 {
450448
tabWindow.alphaValue = 0
451449
}
450+
if !UserDefaults.shared.value(for: \.chatPanelInASeparateWindow) {
451+
if chatWindow.alphaValue != 0 {
452+
chatWindow.alphaValue = 0
453+
}
454+
}
452455
}
453456

454457
guard UserDefaults.shared.value(for: \.suggestionPresentationMode) == .floatingWidget
455458
else {
456459
hide()
457460
return
458461
}
459-
462+
460463
let detachChat = UserDefaults.shared.value(for: \.chatPanelInASeparateWindow)
461464

462465
if detachChat {
@@ -498,9 +501,16 @@ extension SuggestionWidgetController {
498501
tabWindow.setFrame(result.tabFrame, display: false, animate: animated)
499502
suggestionPanelViewModel.alignTopToAnchor = result.alignPanelTopToAnchor
500503
}
501-
502-
if chatWindow.alphaValue == 0 {
504+
505+
if detachChat {
506+
if chatWindow.alphaValue == 0 {
507+
chatWindow.setFrame(panelWindow.frame, display: false, animate: false)
508+
}
509+
} else {
503510
chatWindow.setFrame(panelWindow.frame, display: false, animate: false)
511+
if chatWindow.alphaValue != 1 {
512+
chatWindow.alphaValue = 1
513+
}
504514
}
505515

506516
if panelWindow.alphaValue != 1 {
@@ -552,8 +562,70 @@ extension SuggestionWidgetController {
552562
}
553563
}
554564

565+
extension SuggestionWidgetController: NSWindowDelegate {
566+
public func windowWillMove(_ notification: Notification) {
567+
guard (notification.object as? NSWindow) === chatWindow else { return }
568+
Task { @MainActor in
569+
await Task.yield()
570+
guard chatWindow.isBeingDragged else { return }
571+
UserDefaults.shared.set(true, for: \.chatPanelInASeparateWindow)
572+
}
573+
}
574+
575+
public func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize {
576+
guard sender === chatWindow else { return frameSize }
577+
Task { @MainActor in
578+
await Task.yield()
579+
guard chatWindow.isBeingDragged else { return }
580+
UserDefaults.shared.set(true, for: \.chatPanelInASeparateWindow)
581+
}
582+
return frameSize
583+
}
584+
}
585+
555586
class CanBecomeKeyWindow: NSWindow {
556587
var canBecomeKeyChecker: () -> Bool = { true }
557588
override var canBecomeKey: Bool { canBecomeKeyChecker() }
558589
override var canBecomeMain: Bool { canBecomeKeyChecker() }
559590
}
591+
592+
class ChatWindow: NSWindow {
593+
override var canBecomeKey: Bool { true }
594+
override var canBecomeMain: Bool { true }
595+
var isBeingDragged: Bool = false
596+
var dragStartLocation = NSPoint.zero
597+
598+
override func mouseDragged(with event: NSEvent) {
599+
isBeingDragged = true
600+
601+
guard dragStartLocation != .zero else { return }
602+
603+
let screenVisibleFrame = NSScreen.main?.visibleFrame
604+
let windowFrame = frame
605+
var newOrigin = windowFrame.origin
606+
607+
let currentLocation = event.locationInWindow
608+
if dragStartLocation.y > windowFrame.size.height - 40 {
609+
newOrigin.x += (currentLocation.x - dragStartLocation.x)
610+
newOrigin.y += (currentLocation.y - dragStartLocation.y)
611+
612+
if (newOrigin.y + windowFrame.size.height) >
613+
(screenVisibleFrame?.origin.y ?? 0) + (screenVisibleFrame?.size.height ?? 0)
614+
{
615+
newOrigin.y = screenVisibleFrame?.origin
616+
.y ?? 0 + (screenVisibleFrame?.size.height ?? 0 - windowFrame.size.height)
617+
}
618+
UserDefaults.shared.set(true, for: \.chatPanelInASeparateWindow)
619+
setFrameOrigin(newOrigin)
620+
}
621+
}
622+
623+
override func mouseUp(with event: NSEvent) {
624+
isBeingDragged = false
625+
dragStartLocation = .zero
626+
}
627+
628+
override func mouseDown(with event: NSEvent) {
629+
dragStartLocation = event.locationInWindow
630+
}
631+
}

0 commit comments

Comments
 (0)