From c9151798f25768abbb9cc70a7d337f0fd76de66f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 7 May 2026 19:35:22 +0700 Subject: [PATCH] feat: persist window size, position, and full screen state across launches --- CHANGELOG.md | 2 + .../MainSplitViewController.swift | 9 - .../Infrastructure/TabWindowController.swift | 2 +- .../Infrastructure/WindowFramePolicy.swift | 76 ++++++++ .../Infrastructure/WindowManager.swift | 4 +- .../WindowStateController.swift | 167 ++++++++++++++++++ .../Extensions/NSWindow+FrameAutosave.swift | 15 -- TablePro/TableProApp.swift | 1 + .../Feedback/FeedbackWindowController.swift | 3 +- .../WindowChromeConfigurator.swift | 5 + .../Results/JSONViewerWindowController.swift | 6 +- .../Services/WindowFramePolicyTests.swift | 108 +++++++++++ 12 files changed, 367 insertions(+), 31 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/WindowFramePolicy.swift create mode 100644 TablePro/Core/Services/Infrastructure/WindowStateController.swift delete mode 100644 TablePro/Extensions/NSWindow+FrameAutosave.swift create mode 100644 TableProTests/Services/WindowFramePolicyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f4830bc9..f1a77154a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New tab via Cmd+T no longer flashes focus back to the previous tab in the same window group - Cmd+X with no selection cuts the current line, matching VS Code, Sublime, and Xcode (#1075) +- Editor windows now restore their last size, position, and full screen state on launch instead of opening at a default 1200x800 every time +- First launch picks an initial editor size based on the connected display (about 85% of the visible area) instead of a fixed 1200x800 ### Added diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 765b86f5d..f2e0704a2 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -167,15 +167,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi super.viewWillAppear() guard let window = view.window else { return } - let defaultSize = NSSize(width: 1_200, height: 800) - if window.frame.width < defaultSize.width || window.frame.height < defaultSize.height { - window.setContentSize(NSSize( - width: max(window.frame.width, defaultSize.width), - height: max(window.frame.height, defaultSize.height) - )) - window.center() - } - window.title = windowTitle if let session = currentSession { window.subtitle = session.connection.name diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index aac5df115..d2729fee8 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -74,7 +74,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.identifier = NSUserInterfaceItemIdentifier("main") window.minSize = NSSize(width: 720, height: 480) window.isRestorable = false - window.applyAutosaveName("MainEditorWindow") + WindowStateController.shared.install(on: window, policy: .editor) window.toolbarStyle = .unified // Hide the window title ("Query 1 / TablePro") embedded in the unified // toolbar — otherwise it claims leading space and pushes our navigation diff --git a/TablePro/Core/Services/Infrastructure/WindowFramePolicy.swift b/TablePro/Core/Services/Infrastructure/WindowFramePolicy.swift new file mode 100644 index 000000000..0bac2e6b0 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/WindowFramePolicy.swift @@ -0,0 +1,76 @@ +// +// WindowFramePolicy.swift +// TablePro +// + +import AppKit +import Foundation + +internal struct WindowFramePolicy: Sendable { + let autosaveName: NSWindow.FrameAutosaveName + let fullScreenStateKey: String + let firstRunSizing: FirstRunSizing +} + +internal extension WindowFramePolicy { + enum FirstRunSizing: Sendable { + case preserveContentSize + case fractionOfMainScreen(fraction: CGSize, minimum: NSSize, maximum: NSSize?) + + func contentSize(for screenFrame: NSRect) -> NSSize? { + switch self { + case .preserveContentSize: + return nil + case let .fractionOfMainScreen(fraction, minimum, maximum): + let proposedWidth = screenFrame.width * fraction.width + let proposedHeight = screenFrame.height * fraction.height + + let minClampedWidth = max(proposedWidth, minimum.width) + let minClampedHeight = max(proposedHeight, minimum.height) + + let maxWidth = min(maximum?.width ?? .greatestFiniteMagnitude, screenFrame.width) + let maxHeight = min(maximum?.height ?? .greatestFiniteMagnitude, screenFrame.height) + + let width = min(minClampedWidth, maxWidth) + let height = min(minClampedHeight, maxHeight) + return NSSize(width: width.rounded(), height: height.rounded()) + } + } + } + + static let editor = WindowFramePolicy( + autosaveName: "MainEditorWindow", + fullScreenStateKey: "com.TablePro.windowState.editor.isFullScreen", + firstRunSizing: .fractionOfMainScreen( + fraction: CGSize(width: 0.85, height: 0.85), + minimum: NSSize(width: 1_200, height: 800), + maximum: nil + ) + ) + + static let jsonViewer = WindowFramePolicy( + autosaveName: "JSONViewerWindow", + fullScreenStateKey: "com.TablePro.windowState.jsonViewer.isFullScreen", + firstRunSizing: .fractionOfMainScreen( + fraction: CGSize(width: 0.45, height: 0.55), + minimum: NSSize(width: 640, height: 500), + maximum: NSSize(width: 1_100, height: 900) + ) + ) + + static let integrationsActivity = WindowFramePolicy( + autosaveName: "IntegrationsActivityWindow", + fullScreenStateKey: "com.TablePro.windowState.integrationsActivity.isFullScreen", + firstRunSizing: .fractionOfMainScreen( + fraction: CGSize(width: 0.55, height: 0.65), + minimum: NSSize(width: 960, height: 600), + maximum: NSSize(width: 1_400, height: 1_000) + ) + ) + + static let feedback = WindowFramePolicy( + autosaveName: "FeedbackWindow", + fullScreenStateKey: "com.TablePro.windowState.feedback.isFullScreen", + firstRunSizing: .preserveContentSize + ) +} diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 27af3c145..e4b8a189a 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -71,7 +71,9 @@ internal final class WindowManager { "[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) } else { - window.center() + if !WindowStateController.shared.hasPriorState(for: .editor) { + window.center() + } window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) Self.lifecycleLogger.info( diff --git a/TablePro/Core/Services/Infrastructure/WindowStateController.swift b/TablePro/Core/Services/Infrastructure/WindowStateController.swift new file mode 100644 index 000000000..45ae35652 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/WindowStateController.swift @@ -0,0 +1,167 @@ +// +// WindowStateController.swift +// TablePro +// + +import AppKit +import Foundation +import os + +@MainActor +internal final class WindowStateController { + static let shared = WindowStateController() + + private static let logger = Logger(subsystem: "com.TablePro", category: "WindowState") + + private let defaults: UserDefaults + private var bindings: [ObjectIdentifier: WindowStateBinding] = [:] + + private init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + private func applyFirstRunFrame(to window: NSWindow, policy: WindowFramePolicy) { + let screenFrame = (window.screen ?? NSScreen.main)?.visibleFrame + ?? NSRect(x: 0, y: 0, width: 1_440, height: 900) + if let size = policy.firstRunSizing.contentSize(for: screenFrame) { + window.setContentSize(size) + } + window.center() + } + + fileprivate func releaseBinding(forWindowKey key: ObjectIdentifier) { + bindings.removeValue(forKey: key) + } +} + +internal extension WindowStateController { + func install(on window: NSWindow, policy: WindowFramePolicy) { + window.setFrameAutosaveName(policy.autosaveName) + + if !window.setFrameUsingName(policy.autosaveName) { + applyFirstRunFrame(to: window, policy: policy) + } + + let key = ObjectIdentifier(window) + bindings[key]?.invalidate() + + let restorePending = defaults.bool(forKey: policy.fullScreenStateKey) + bindings[key] = WindowStateBinding( + windowKey: key, + window: window, + policy: policy, + defaults: defaults, + restoreFullScreenOnFirstKey: restorePending, + owner: self + ) + } + + func hasPriorState(for policy: WindowFramePolicy) -> Bool { + let frameKey = "NSWindow Frame \(policy.autosaveName)" + let hasSavedFrame = defaults.object(forKey: frameKey) != nil + let wasInFullScreen = defaults.bool(forKey: policy.fullScreenStateKey) + return hasSavedFrame || wasInFullScreen + } +} + +@MainActor +private final class WindowStateBinding { + private let windowKey: ObjectIdentifier + private weak var window: NSWindow? + private let policy: WindowFramePolicy + private let defaults: UserDefaults + private weak var owner: WindowStateController? + + private var liveObservers: [NSObjectProtocol] = [] + private var fullScreenRestoreObserver: NSObjectProtocol? + + init( + windowKey: ObjectIdentifier, + window: NSWindow, + policy: WindowFramePolicy, + defaults: UserDefaults, + restoreFullScreenOnFirstKey: Bool, + owner: WindowStateController + ) { + self.windowKey = windowKey + self.window = window + self.policy = policy + self.defaults = defaults + self.owner = owner + + attachLiveObservers() + if restoreFullScreenOnFirstKey { + attachFullScreenRestoreObserver() + } + } + + func invalidate() { + let center = NotificationCenter.default + for observer in liveObservers { + center.removeObserver(observer) + } + liveObservers.removeAll() + if let fullScreenRestoreObserver { + center.removeObserver(fullScreenRestoreObserver) + self.fullScreenRestoreObserver = nil + } + } + + private func attachLiveObservers() { + guard let window else { return } + let center = NotificationCenter.default + + liveObservers.append(center.addObserver( + forName: NSWindow.willEnterFullScreenNotification, + object: window, + queue: .main + ) { [defaults, policy] _ in + defaults.set(true, forKey: policy.fullScreenStateKey) + }) + + liveObservers.append(center.addObserver( + forName: NSWindow.didExitFullScreenNotification, + object: window, + queue: .main + ) { [defaults, policy] _ in + defaults.set(false, forKey: policy.fullScreenStateKey) + }) + + liveObservers.append(center.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.invalidate() + self.owner?.releaseBinding(forWindowKey: self.windowKey) + } + }) + } + + private func attachFullScreenRestoreObserver() { + guard let window else { return } + let center = NotificationCenter.default + + fullScreenRestoreObserver = center.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.performFullScreenRestore() + } + } + } + + private func performFullScreenRestore() { + guard let window else { return } + if let observer = fullScreenRestoreObserver { + NotificationCenter.default.removeObserver(observer) + fullScreenRestoreObserver = nil + } + guard !window.styleMask.contains(.fullScreen) else { return } + window.toggleFullScreen(nil) + } +} diff --git a/TablePro/Extensions/NSWindow+FrameAutosave.swift b/TablePro/Extensions/NSWindow+FrameAutosave.swift deleted file mode 100644 index da9eb8ad2..000000000 --- a/TablePro/Extensions/NSWindow+FrameAutosave.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// NSWindow+FrameAutosave.swift -// TablePro -// - -import AppKit - -extension NSWindow { - func applyAutosaveName(_ name: NSWindow.FrameAutosaveName) { - setFrameAutosaveName(name) - if !setFrameUsingName(name) { - center() - } - } -} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index a3b5463b3..2ab655007 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -659,6 +659,7 @@ struct TableProApp: App { Window("Integrations Activity", id: SceneId.integrationsActivity) { IntegrationsActivityView() + .background(WindowChromeConfigurator(statePolicy: .integrationsActivity)) } .windowResizability(.contentMinSize) .defaultSize(width: 960, height: 600) diff --git a/TablePro/Views/Feedback/FeedbackWindowController.swift b/TablePro/Views/Feedback/FeedbackWindowController.swift index 0b1e569e9..083bc1f01 100644 --- a/TablePro/Views/Feedback/FeedbackWindowController.swift +++ b/TablePro/Views/Feedback/FeedbackWindowController.swift @@ -9,7 +9,6 @@ import SwiftUI @MainActor final class FeedbackWindowController { static let shared = FeedbackWindowController() - private static let autosaveName: NSWindow.FrameAutosaveName = "FeedbackWindow" private var panel: NSPanel? private var closeObserver: NSObjectProtocol? private let viewModel = FeedbackViewModel() @@ -43,7 +42,7 @@ final class FeedbackWindowController { panel.standardWindowButton(.miniaturizeButton)?.isHidden = true panel.standardWindowButton(.zoomButton)?.isHidden = true panel.contentView = hostingView - panel.applyAutosaveName(Self.autosaveName) + WindowStateController.shared.install(on: panel, policy: .feedback) panel.makeKeyAndOrderFront(nil) self.panel = panel diff --git a/TablePro/Views/Infrastructure/WindowChromeConfigurator.swift b/TablePro/Views/Infrastructure/WindowChromeConfigurator.swift index 5b5b3f035..c8d4f38ee 100644 --- a/TablePro/Views/Infrastructure/WindowChromeConfigurator.swift +++ b/TablePro/Views/Infrastructure/WindowChromeConfigurator.swift @@ -11,6 +11,7 @@ internal struct WindowChromeConfigurator: NSViewRepresentable { var fullScreenable: Bool = true var hideMiniaturizeButton: Bool = false var hideZoomButton: Bool = false + var statePolicy: WindowFramePolicy? func makeNSView(context: Context) -> NSView { let view = ChromeHostView() @@ -50,5 +51,9 @@ private final class ChromeHostView: NSView { window.standardWindowButton(.miniaturizeButton)?.isHidden = config.hideMiniaturizeButton window.standardWindowButton(.zoomButton)?.isHidden = config.hideZoomButton + + if let policy = config.statePolicy { + WindowStateController.shared.install(on: window, policy: policy) + } } } diff --git a/TablePro/Views/Results/JSONViewerWindowController.swift b/TablePro/Views/Results/JSONViewerWindowController.swift index 04ccf5c70..c65d6ec38 100644 --- a/TablePro/Views/Results/JSONViewerWindowController.swift +++ b/TablePro/Views/Results/JSONViewerWindowController.swift @@ -11,7 +11,6 @@ final class JSONViewerWindowController { private static var activeWindows: [ObjectIdentifier: JSONViewerWindowController] = [:] private static let defaultSize = NSSize(width: 640, height: 500) private static let minSize = NSSize(width: 400, height: 300) - private static let autosaveName: NSWindow.FrameAutosaveName = "JSONViewerWindow" private var window: NSWindow? private var closeObserver: NSObjectProtocol? @@ -39,7 +38,8 @@ final class JSONViewerWindowController { defer: false ) window.identifier = NSUserInterfaceItemIdentifier("json-viewer") - window.title = columnName.map { "JSON — \($0)" } ?? String(localized: "JSON Viewer") + window.title = columnName.map { String(format: String(localized: "JSON: %@"), $0) } + ?? String(localized: "JSON Viewer") window.isReleasedWhenClosed = false window.minSize = Self.minSize window.collectionBehavior = [.fullScreenPrimary] @@ -71,7 +71,7 @@ final class JSONViewerWindowController { } } - window.applyAutosaveName(Self.autosaveName) + WindowStateController.shared.install(on: window, policy: .jsonViewer) window.makeKeyAndOrderFront(nil) } } diff --git a/TableProTests/Services/WindowFramePolicyTests.swift b/TableProTests/Services/WindowFramePolicyTests.swift new file mode 100644 index 000000000..4c5ddc745 --- /dev/null +++ b/TableProTests/Services/WindowFramePolicyTests.swift @@ -0,0 +1,108 @@ +// +// WindowFramePolicyTests.swift +// TableProTests +// + +import AppKit +import Foundation +import Testing + +@testable import TablePro + +@MainActor +@Suite("WindowFramePolicy.FirstRunSizing.contentSize") +struct WindowFramePolicyFirstRunSizingTests { + @Test("preserveContentSize returns nil regardless of screen size") + func preserveContentSizeReturnsNil() { + let sizing = WindowFramePolicy.FirstRunSizing.preserveContentSize + let smallScreen = NSRect(x: 0, y: 0, width: 800, height: 600) + let largeScreen = NSRect(x: 0, y: 0, width: 5_120, height: 2_880) + + #expect(sizing.contentSize(for: smallScreen) == nil) + #expect(sizing.contentSize(for: largeScreen) == nil) + } + + @Test("fractionOfMainScreen scales width and height by the fraction") + func fractionScalesByScreen() { + let sizing = WindowFramePolicy.FirstRunSizing.fractionOfMainScreen( + fraction: CGSize(width: 0.5, height: 0.5), + minimum: NSSize(width: 100, height: 100), + maximum: nil + ) + let screen = NSRect(x: 0, y: 0, width: 1_600, height: 1_000) + + let result = sizing.contentSize(for: screen) + #expect(result == NSSize(width: 800, height: 500)) + } + + @Test("minimum size floor is honored when fraction would go below it") + func minimumIsHonored() { + let sizing = WindowFramePolicy.FirstRunSizing.fractionOfMainScreen( + fraction: CGSize(width: 0.5, height: 0.5), + minimum: NSSize(width: 1_200, height: 800), + maximum: nil + ) + let smallScreen = NSRect(x: 0, y: 0, width: 1_400, height: 900) + + let result = sizing.contentSize(for: smallScreen) + #expect(result == NSSize(width: 1_200, height: 800)) + } + + @Test("minimum may exceed screen but result is then clamped to screen") + func minimumClampsToScreenWhenScreenIsTooSmall() { + let sizing = WindowFramePolicy.FirstRunSizing.fractionOfMainScreen( + fraction: CGSize(width: 0.85, height: 0.85), + minimum: NSSize(width: 1_200, height: 800), + maximum: nil + ) + let tinyScreen = NSRect(x: 0, y: 0, width: 1_000, height: 700) + + let result = sizing.contentSize(for: tinyScreen) + #expect(result == NSSize(width: 1_000, height: 700)) + } + + @Test("maximum size cap is honored when fraction would exceed it") + func maximumIsHonored() { + let sizing = WindowFramePolicy.FirstRunSizing.fractionOfMainScreen( + fraction: CGSize(width: 0.9, height: 0.9), + minimum: NSSize(width: 100, height: 100), + maximum: NSSize(width: 1_100, height: 900) + ) + let bigScreen = NSRect(x: 0, y: 0, width: 5_120, height: 2_880) + + let result = sizing.contentSize(for: bigScreen) + #expect(result == NSSize(width: 1_100, height: 900)) + } + + @Test("editor policy on a 1440x900 display yields ~85% sized content") + func editorPolicyOnLaptopDisplay() { + let screen = NSRect(x: 0, y: 0, width: 1_440, height: 900) + let result = WindowFramePolicy.editor.firstRunSizing.contentSize(for: screen) + + #expect(result?.width == 1_224) + #expect(result?.height == 800) + } + + @Test("editor policy on a 5120x2880 display still yields exactly 85% (no max)") + func editorPolicyOnRetinaDisplay() { + let screen = NSRect(x: 0, y: 0, width: 5_120, height: 2_880) + let result = WindowFramePolicy.editor.firstRunSizing.contentSize(for: screen) + + #expect(result?.width == 4_352) + #expect(result?.height == 2_448) + } + + @Test("integrationsActivity policy caps at maximum on big displays") + func integrationsActivityCapsAtMaximum() { + let screen = NSRect(x: 0, y: 0, width: 5_120, height: 2_880) + let result = WindowFramePolicy.integrationsActivity.firstRunSizing.contentSize(for: screen) + + #expect(result == NSSize(width: 1_400, height: 1_000)) + } + + @Test("feedback policy preserves content size") + func feedbackPreservesContentSize() { + let screen = NSRect(x: 0, y: 0, width: 1_440, height: 900) + #expect(WindowFramePolicy.feedback.firstRunSizing.contentSize(for: screen) == nil) + } +}