Skip to content

Commit a73de45

Browse files
committed
refactor: drive frame autosave from NSWindowDelegate methods, no flicker
1 parent 92adc2c commit a73de45

1 file changed

Lines changed: 20 additions & 118 deletions

File tree

TablePro/Core/Services/Infrastructure/TabWindowController.swift

Lines changed: 20 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,11 @@
22
// TabWindowController.swift
33
// TablePro
44
//
5-
// NSWindowController for an editor-tab-window. Replaces the SwiftUI
6-
// `WindowGroup(id: "main", for: EditorTabPayload.self)` scene.
7-
//
8-
// Phase 1 scope: window creation, NSHostingView installation, tabbing
9-
// configuration. Existing MainContentView lifecycle hooks (.onAppear,
10-
// .onDisappear, NSWindow notification observers, .userActivity) continue to
11-
// work unchanged — this controller's job in Phase 1 is limited to replacing
12-
// SwiftUI scene-driven window construction.
13-
//
14-
// Phase 2 will migrate lifecycle responsibilities (markActivated, teardown,
15-
// userActivity, didBecomeKey/didResignKey) into NSWindowDelegate methods
16-
// on this controller.
17-
//
185

196
import AppKit
207
import os
218
import SwiftUI
229

23-
/// NSWindow subclass that routes the standard tab-related responder selectors
24-
/// (`performClose:`, `newWindowForTab:`) through the coordinator. Menus and
25-
/// toolbar buttons reach this via `NSApp.sendAction(_:to:nil:from:)`, the same
26-
/// pattern AppKit uses for `selectNextTab:` and `selectPreviousTab:`.
2710
@MainActor
2811
private final class EditorWindow: NSWindow {
2912
override func performClose(_ sender: Any?) {
@@ -48,18 +31,11 @@ private final class EditorWindow: NSWindow {
4831
@MainActor
4932
internal final class TabWindowController: NSWindowController, NSWindowDelegate {
5033
private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle")
51-
private static let frameLogger = Logger(subsystem: "com.TablePro", category: "WindowFrame")
5234

5335
internal static let frameAutosaveName: NSWindow.FrameAutosaveName = "MainEditorWindow"
5436

55-
private static var frameDefaultsKey: String { "NSWindow Frame \(frameAutosaveName)" }
56-
5737
internal static var hasSavedFrame: Bool {
58-
UserDefaults.standard.object(forKey: frameDefaultsKey) != nil
59-
}
60-
61-
private static func currentSavedFrameString() -> String {
62-
UserDefaults.standard.string(forKey: frameDefaultsKey) ?? "<nil>"
38+
UserDefaults.standard.object(forKey: "NSWindow Frame \(frameAutosaveName)") != nil
6339
}
6440

6541
private lazy var dataGridFieldEditor: DataGridFieldEditor = {
@@ -70,18 +46,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
7046

7147
internal let payload: EditorTabPayload
7248

73-
/// Stable identifier for this controller. Distinct from the
74-
/// `MainContentView.@State windowId` used inside WindowLifecycleMonitor —
75-
/// that one remains the authoritative per-view UUID in Phase 1. Phase 2
76-
/// will unify them on this controller's identifier.
7749
internal let controllerId: UUID
7850

79-
/// NSUserActivity published while this window is key, so Handoff and
80-
/// other continuity flows can pick up the connection (and table, if
81-
/// viewing one). Replaces the SwiftUI `.userActivity(...)` modifier we
82-
/// removed in Phase 2 — `.userActivity` requires a Scene context and
83-
/// emitted `Cannot use Scene methods for URL, NSUserActivity...` warnings
84-
/// when used inside an `NSHostingView`.
8551
private var activity: NSUserActivity?
8652

8753
internal init(payload: EditorTabPayload, sessionState: SessionStateFactory.SessionState? = nil) {
@@ -98,49 +64,22 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
9864
window.minSize = NSSize(width: 720, height: 480)
9965
window.isRestorable = false
10066
window.toolbarStyle = .unified
101-
// Hide the window title ("Query 1 / TablePro") embedded in the unified
102-
// toolbar — otherwise it claims leading space and pushes our navigation
103-
// items to the right of it. Tab group's tab bar already shows the same
104-
// "Query N" label, so no information is lost. The Principal toolbar item
105-
// continues to show connection name + DB version.
10667
window.titleVisibility = .hidden
10768
window.tabbingMode = .preferred
10869
window.tabbingIdentifier = WindowManager.tabbingIdentifier(for: payload.connectionId)
10970
window.collectionBehavior.insert([.fullScreenPrimary, .managed])
11071

111-
// NSSplitViewController as contentViewController so .toggleSidebar and
112-
// .sidebarTrackingSeparator find the split view via the responder chain.
72+
window.setFrameAutosaveName(Self.frameAutosaveName)
73+
_ = window.setFrameUsingName(Self.frameAutosaveName)
74+
11375
let splitVC = MainSplitViewController(payload: payload, sessionState: sessionState)
11476
window.contentViewController = splitVC
11577

11678
super.init(window: window)
11779

118-
Self.frameLogger.notice("[init] before-autosave persistedFrame=\(Self.currentSavedFrameString(), privacy: .public) windowFrame=\(NSStringFromRect(window.frame), privacy: .public)")
119-
120-
self.windowFrameAutosaveName = Self.frameAutosaveName
121-
Self.frameLogger.notice("[init] after windowFrameAutosaveName setter windowAutosaveName=\(window.frameAutosaveName, privacy: .public) windowFrame=\(NSStringFromRect(window.frame), privacy: .public)")
122-
123-
let restored = window.setFrameUsingName(Self.frameAutosaveName)
124-
Self.frameLogger.notice("[init] explicit setFrameUsingName returned=\(restored, privacy: .public) windowFrame=\(NSStringFromRect(window.frame), privacy: .public)")
125-
126-
installFrameAutosaveTrace(on: window)
127-
128-
// Keep the controller alive after the window closes so NSWindowDelegate
129-
// hooks have time to run teardown. WindowManager drops its strong
130-
// reference on willClose, which triggers dealloc.
13180
window.isReleasedWhenClosed = false
132-
133-
// Become the window's delegate so didBecomeKey/didResignKey/willClose
134-
// dispatch to methods on this controller — eliminates the global
135-
// NotificationCenter fan-out that previously ran every ContentView
136-
// instance's observer per focus change.
13781
window.delegate = self
13882

139-
// Toolbar is installed by MainSplitViewController.viewWillAppear when
140-
// the session state is available. NSSplitViewController does not
141-
// overwrite window.toolbar (unlike NavigationSplitView), so no KVO
142-
// workaround is needed.
143-
14483
Self.lifecycleLogger.info(
14584
"[open] TabWindowController.init payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) controllerId=\(self.controllerId, privacy: .public) eagerToolbar=\(sessionState != nil)"
14685
)
@@ -151,46 +90,29 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
15190
fatalError("TabWindowController does not support NSCoder init")
15291
}
15392

154-
private var frameTraceObservers: [NSObjectProtocol] = []
155-
156-
private func installFrameAutosaveTrace(on window: NSWindow) {
157-
let center = NotificationCenter.default
158-
159-
frameTraceObservers.append(center.addObserver(
160-
forName: NSWindow.didResizeNotification,
161-
object: window,
162-
queue: .main
163-
) { [weak window] _ in
164-
guard let window else { return }
165-
Self.frameLogger.notice("[event] didResize windowFrame=\(NSStringFromRect(window.frame), privacy: .public) inLiveResize=\(window.inLiveResize, privacy: .public) persistedFrame=\(Self.currentSavedFrameString(), privacy: .public)")
166-
})
167-
168-
frameTraceObservers.append(center.addObserver(
169-
forName: NSWindow.didMoveNotification,
170-
object: window,
171-
queue: .main
172-
) { [weak window] _ in
173-
guard let window else { return }
174-
Self.frameLogger.notice("[event] didMove windowFrame=\(NSStringFromRect(window.frame), privacy: .public) persistedFrame=\(Self.currentSavedFrameString(), privacy: .public)")
175-
})
176-
177-
frameTraceObservers.append(center.addObserver(
178-
forName: NSWindow.didEndLiveResizeNotification,
179-
object: window,
180-
queue: .main
181-
) { [weak window] _ in
182-
guard let window else { return }
183-
Self.frameLogger.notice("[event] didEndLiveResize windowFrame=\(NSStringFromRect(window.frame), privacy: .public) persistedFrame=\(Self.currentSavedFrameString(), privacy: .public)")
184-
})
185-
}
186-
18793
// MARK: - NSWindowDelegate
18894

18995
func windowWillReturnFieldEditor(_ sender: NSWindow, to client: Any?) -> Any? {
19096
guard client is CellTextField else { return nil }
19197
return dataGridFieldEditor
19298
}
19399

100+
internal func windowDidResize(_ notification: Notification) {
101+
guard let window = notification.object as? NSWindow else { return }
102+
guard !window.inLiveResize else { return }
103+
window.saveFrame(usingName: Self.frameAutosaveName)
104+
}
105+
106+
internal func windowDidEndLiveResize(_ notification: Notification) {
107+
guard let window = notification.object as? NSWindow else { return }
108+
window.saveFrame(usingName: Self.frameAutosaveName)
109+
}
110+
111+
internal func windowDidMove(_ notification: Notification) {
112+
guard let window = notification.object as? NSWindow else { return }
113+
window.saveFrame(usingName: Self.frameAutosaveName)
114+
}
115+
194116
internal func windowDidBecomeKey(_ notification: Notification) {
195117
let seq = MainContentCoordinator.nextSwitchSeq()
196118
let t0 = Date()
@@ -235,14 +157,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
235157
guard let window = notification.object as? NSWindow else { return }
236158
Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) controllerId=\(self.controllerId, privacy: .public)")
237159

238-
Self.frameLogger.notice("[close] before saveFrame windowFrame=\(NSStringFromRect(window.frame), privacy: .public) styleMask.fullScreen=\(window.styleMask.contains(.fullScreen), privacy: .public) persistedFrame=\(Self.currentSavedFrameString(), privacy: .public)")
239160
window.saveFrame(usingName: Self.frameAutosaveName)
240-
Self.frameLogger.notice("[close] after saveFrame persistedFrame=\(Self.currentSavedFrameString(), privacy: .public)")
241-
242-
for observer in frameTraceObservers {
243-
NotificationCenter.default.removeObserver(observer)
244-
}
245-
frameTraceObservers.removeAll()
246161

247162
if let splitVC = window.contentViewController as? MainSplitViewController {
248163
splitVC.invalidateToolbar()
@@ -262,10 +177,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
262177

263178
// MARK: - NSUserActivity
264179

265-
/// Publish (or refresh) this window's NSUserActivity. Called by
266-
/// `windowDidBecomeKey` and by `MainContentView` when the selected tab
267-
/// changes — only the second case is a no-op when the window isn't key
268-
/// (Handoff only cares about the active activity).
269180
internal func refreshUserActivity() {
270181
guard let window, window.isKeyWindow,
271182
let coordinator = MainContentCoordinator.coordinator(forWindow: window)
@@ -279,8 +190,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
279190
let tableName: String? = (selectedTab?.tabType == .table) ? selectedTab?.tableContext.tableName : nil
280191
let activityType = tableName != nil ? "com.TablePro.viewTable" : "com.TablePro.viewConnection"
281192

282-
// Recreate when the activity type flips between viewConnection and
283-
// viewTable — NSUserActivity.activityType is immutable.
284193
if activity?.activityType != activityType {
285194
activity?.invalidate()
286195
let newActivity = NSUserActivity(activityType: activityType)
@@ -296,13 +205,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
296205
}
297206
activity.userInfo = info
298207

299-
// Always promote to current. Both call sites (`windowDidBecomeKey` and
300-
// `refreshUserActivity` which guards on `window.isKeyWindow`) only
301-
// invoke this method when the window owns Handoff. The previous
302-
// `becomeCurrent: Bool` parameter dropped Continuity mid-session
303-
// whenever the user switched between table and query tabs in the
304-
// same window — the type-flip branch above invalidated the old
305-
// activity but never promoted the replacement.
306208
activity.becomeCurrent()
307209
}
308210
}

0 commit comments

Comments
 (0)