From 9b41a9a7aa11a4e51d25dfde24f89d2e6b6889eb Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 18 Sep 2026 17:25:37 +0800 Subject: [PATCH] keep window size --- Memory/ARCHITECTURE.md | 12 + Memory/REFACTOR.md | 12 + TFM/App/AppModel.swift | 7 +- TFM/App/DesktopSettingsKey.swift | 2 + TFM/App/RootView.swift | 30 -- TFM/App/TermindApp.swift | 6 +- TFM/App/TermindCommands.swift | 12 +- .../Commands/AppCommandRegistry.swift | 2 +- TFM/Platform/MainWindowCoordinator.swift | 178 +++++++++-- TFM/Platform/MainWindowSizeStore.swift | 33 ++ TFMTests/BroadcastInputTests.swift | 1 + TFMTests/MainWindowSizingTests.swift | 282 ++++++++++++++++++ 12 files changed, 509 insertions(+), 68 deletions(-) create mode 100644 TFM/Platform/MainWindowSizeStore.swift create mode 100644 TFMTests/MainWindowSizingTests.swift diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 6ad6c9e9..90bca2da 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -64,6 +64,18 @@ cleanup is performed. windows. Creating a window starts on Welcome; Dock reopening preserves the existing destination, while the Metrics entry explicitly selects Metrics. Restoring a scene never restores or reconnects live Workspaces. +- Desktop new-window requests snapshot their source's ordinary content size; + repeated registration cannot reapply it. The native registration bridge sets + the full-size content style before sizing, so titlebar height never accumulates + across copies. Command-N opens Connect in a fullscreen source; explicit Quick + Open New Window still creates a window using its pre-fullscreen size. + The last-used main window's ordinary size is device-local UserDefaults state, + excluded from sync. Fullscreen transitions and auxiliary windows cannot replace + it, and termination freezes it before window teardown. A cold scene uses this + default only when macOS has no individual restored frame; restoring multiple + scenes never applies the shared size over their frames. Tests and screenshot + demos use an in-memory size store. Window positions remain system-owned except + for the existing workspace tear-off placement. - Desktop Command-W closes the selected Workspace's focused pane first. Closing the last pane removes its Workspace but keeps the window open. Without a selected Workspace it requests native window dismissal. All user window-close paths share diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 91d66aa9..30fcddff 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -468,3 +468,15 @@ The operator approved R1, R2, and R3 in that order. R4–R6 remain outside this changed to hide these failures. The macOS full run has the previously documented `SpeechWaveformViewTests.waveformRespondsToLevelsAndReduceMotionMakesItStable` failure; its source and test remain unchanged. + +## Consolidate Main Window Registration and Content Style (2026-09-18) + +- Evidence: `rg` found `RootView` mounting both `MainWindowRegistration` and + `FullHeightWindowContent`; the latter existed only to set two native window + properties. Separate attachment callbacks could size a window before its + full-size content coordinate system was established. +- Action: set the style and titlebar transparency in the existing registration + bridge before registering, and remove the redundant representable and NSView. + Both operations remain in the macOS platform layer. +- Reduction: remove 30 lines of bridge plumbing, adding 5 lines at registration. +- Status: implemented; focused and full-suite verification pending. diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index a48c3989..e6da45a3 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -76,7 +76,8 @@ final class AppModel { let onboarding: OnboardingController let appReview: AppReviewController let serverMetrics: ServerMetricsModel - @ObservationIgnored lazy var mainWindows = MainWindowCoordinator(app: self) + let windowSizeStore: MainWindowSizeStore + @ObservationIgnored lazy var mainWindows = MainWindowCoordinator(app: self, sizes: windowSizeStore) let connections: RemoteConnectionCoordinator let serialPorts: any SerialPortServing let hostRepository: any HostRepository @@ -141,8 +142,10 @@ final class AppModel { proAccess: ProAccessController = .unrestrictedForTesting(), onboarding: OnboardingController = OnboardingController(), appReview: AppReviewController = AppReviewController(), - serverMetrics suppliedMetrics: ServerMetricsModel? = nil + serverMetrics suppliedMetrics: ServerMetricsModel? = nil, + windowSizeStore: MainWindowSizeStore = MainWindowSizeStore() ) { + self.windowSizeStore = windowSizeStore let dependencies = dependencies ?? .inMemory(hosts: hosts.hosts, groups: hosts.groups) let externalAuthentication = ExternalAuthCoordinator() self.externalAuthentication = externalAuthentication diff --git a/TFM/App/DesktopSettingsKey.swift b/TFM/App/DesktopSettingsKey.swift index b15d828e..6384fd00 100644 --- a/TFM/App/DesktopSettingsKey.swift +++ b/TFM/App/DesktopSettingsKey.swift @@ -11,6 +11,8 @@ nonisolated enum DesktopSettingsKey { static let showMetricsInMenuBar = "metrics.showInMenuBar" /// App-wide appearance. `system` inherits changes from macOS. static let appAppearance = "app.appearance" + /// Last ordinary main-window content size, local to this Mac. + static let mainWindowSize = "window.main.contentSize" // Built-in MCP listener preferences are device-local and never enter Settings sync. static let mcpServerEnabled = "mcpServer.enabled" diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 5efa7de0..0f4c4ee7 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -83,7 +83,6 @@ struct RootView: View { minHeight: Metrics.Window.minHeight, maxHeight: .infinity ) - .background { FullHeightWindowContent() } .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { @@ -386,18 +385,6 @@ struct RootView: View { } } -/// Extends the split view's material behind the titlebar while retaining the -/// native toolbar chrome. -private struct FullHeightWindowContent: NSViewRepresentable { - func makeNSView(context: Context) -> WindowContentBridgeView { - WindowContentBridgeView() - } - - func updateNSView(_ view: WindowContentBridgeView, context: Context) { - view.configureWindow() - } -} - /// Owns window-scoped AppKit integration: key-window onboarding and the remote /// title report. Keeping both on this persistent bridge avoids routing title /// changes through whichever Workspace or pane SwiftUI currently renders. @@ -514,20 +501,3 @@ private final class WindowActivityBridgeView: NSView, WindowRemoteTitleSink { } } } - -private final class WindowContentBridgeView: NSView { - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - configureWindow() - } - - func configureWindow() { - guard let window else { return } - if !window.styleMask.contains(.fullSizeContentView) { - window.styleMask.insert(.fullSizeContentView) - } - if !window.titlebarAppearsTransparent { - window.titlebarAppearsTransparent = true - } - } -} diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index fb19cbbd..e72b2bae 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -118,7 +118,8 @@ struct TermindApp: App { forwardingDefaults: .standard, mcpServerSecrets: secrets ), proAccess: proAccess, - appReview: .live() + appReview: .live(), + windowSizeStore: MainWindowSizeStore(defaults: isRunningTests ? nil : .standard) ) ) } catch { @@ -154,7 +155,7 @@ struct TermindApp: App { #if DEBUG .restorationBehavior(ScreenshotDemoConfiguration.active == nil ? .automatic : .disabled) #endif - .defaultSize(width: Metrics.Window.defaultWidth, height: Metrics.Window.defaultHeight) + .defaultSize(app.windowSizeStore.defaultSize) // All main pages share one content floor. Opening the sidebar lets // the native split view grow a window that cannot fit both columns. .windowResizability(.contentMinSize) @@ -331,6 +332,7 @@ struct TermindApp: App { } private func beginTermination(_ app: AppModel) { + app.mainWindows.saveSizeForTermination() let cleanup = app.shutdownForTermination() Task { @MainActor [weak self] in await cleanup.value diff --git a/TFM/App/TermindCommands.swift b/TFM/App/TermindCommands.swift index cb1b54f7..e071d595 100644 --- a/TFM/App/TermindCommands.swift +++ b/TFM/App/TermindCommands.swift @@ -25,6 +25,9 @@ struct TermindCommands: Commands { return workspace.isSplit ? "Close Pane" : "Close Workspace" } + private var newWindowConnects: Bool { app.mainWindows.isFullScreen(window) } + private var newWindowTitle: LocalizedStringKey { newWindowConnects ? "Connect" : "New Window" } + var body: some Commands { let _ = app.mainWindows.registerWindowCreation { [openWindow] id in openWindow(id: "main", value: id) @@ -37,8 +40,13 @@ struct TermindCommands: Commands { } CommandGroup(replacing: .newItem) { - Button("New Window", systemImage: MenuSymbol.newWindow) { app.mainWindows.newWindow() } - .termindShortcut("n") + Button( + newWindowTitle, + systemImage: newWindowConnects ? MenuSymbol.ssh : MenuSymbol.newWindow + ) { + app.mainWindows.performNewWindowCommand(from: window) + } + .termindShortcut("n") Divider() diff --git a/TFM/Application/Commands/AppCommandRegistry.swift b/TFM/Application/Commands/AppCommandRegistry.swift index 59a9f12b..886c3b96 100644 --- a/TFM/Application/Commands/AppCommandRegistry.swift +++ b/TFM/Application/Commands/AppCommandRegistry.swift @@ -87,7 +87,7 @@ final class AppCommandRegistry { guard let host = app.hostLibrary.host(id) else { return } app.open(host, as: target, in: window) case .newWindow: - app.mainWindows.newWindow() + app.mainWindows.newWindow(from: window) case .moveWorkspaceToNewWindow(let id): app.mainWindows.moveWorkspace(id, from: window) case .newChat: diff --git a/TFM/Platform/MainWindowCoordinator.swift b/TFM/Platform/MainWindowCoordinator.swift index 00099c06..e966ec0c 100644 --- a/TFM/Platform/MainWindowCoordinator.swift +++ b/TFM/Platform/MainWindowCoordinator.swift @@ -10,6 +10,9 @@ final class MainWindowCoordinator { private final class Entry { weak var window: NSWindow? weak var model: WindowModel? + var normalSize: CGSize? + var isTransitioningFullScreen = false + var isReady = false init(window: NSWindow, model: WindowModel) { self.window = window self.model = model @@ -21,11 +24,13 @@ final class MainWindowCoordinator { let workspaceID: UUID? let showsMetrics: Bool let dropPoint: CGPoint? - let sourceSize: CGSize? + /// AppKit content coordinates, including the titlebar for the main + /// window's full-size content view. Never a fullscreen frame. + let sourceSize: CGSize init( source: WindowModel? = nil, workspaceID: UUID? = nil, - showsMetrics: Bool = false, dropPoint: CGPoint? = nil, sourceSize: CGSize? = nil + showsMetrics: Bool = false, dropPoint: CGPoint? = nil, sourceSize: CGSize ) { self.source = source self.workspaceID = workspaceID @@ -36,32 +41,97 @@ final class MainWindowCoordinator { } @ObservationIgnored private weak var app: AppModel? + @ObservationIgnored private let sizes: MainWindowSizeStore @ObservationIgnored private var entries: [Entry] = [] @ObservationIgnored private var requests: [UUID: Request] = [:] @ObservationIgnored private var observers: [any NSObjectProtocol] = [] @ObservationIgnored var activeWorkspaceDrag: WorkspaceWindowDragSession? @ObservationIgnored var createScene: ((UUID) -> Void)? + @ObservationIgnored private var isTerminating = false private(set) var paywallOwnerID: ObjectIdentifier? + private var fullScreenWindows: Set = [] - init(app: AppModel) { + init(app: AppModel, sizes: MainWindowSizeStore) { self.app = app - for name in [NSWindow.willCloseNotification, NSWindow.didBecomeKeyNotification] { + self.sizes = sizes + for name in [ + NSWindow.willCloseNotification, NSWindow.didBecomeKeyNotification, + NSWindow.didResizeNotification, NSWindow.didEndLiveResizeNotification, + NSWindow.willEnterFullScreenNotification, NSWindow.didEnterFullScreenNotification, + NSWindow.willExitFullScreenNotification, NSWindow.didExitFullScreenNotification, + ] { observers.append( NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in guard let window = notification.object as? NSWindow else { return } MainActor.assumeIsolated { - guard let self else { return } - if name == NSWindow.willCloseNotification { - self.close(window) - } else { - self.becameKey(window) - } + self?.handle(name, for: window) } }) } } + private func handle(_ notification: Notification.Name, for native: NSWindow) { + guard let entry = entries.first(where: { $0.window === native }), let model = entry.model else { return } + switch notification { + case NSWindow.willCloseNotification: + close(native) + case NSWindow.didBecomeKeyNotification: + becameKey(native) + case NSWindow.willEnterFullScreenNotification: + recordSize(entry) + entry.isTransitioningFullScreen = true + fullScreenWindows.insert(ObjectIdentifier(model)) + case NSWindow.didEnterFullScreenNotification: + entry.isTransitioningFullScreen = false + fullScreenWindows.insert(ObjectIdentifier(model)) + case NSWindow.willExitFullScreenNotification: + entry.isTransitioningFullScreen = true + case NSWindow.didExitFullScreenNotification: + entry.isTransitioningFullScreen = false + fullScreenWindows.remove(ObjectIdentifier(model)) + recordSize(entry) + default: + recordSize(entry) + } + } + + /// Track every window's ordinary size, but only the most recently used + /// main window updates the fallback. Auxiliary windows never register here. + private func recordSize(_ entry: Entry) { + guard entry.isReady, !isTerminating, !entry.isTransitioningFullScreen, + let native = entry.window, let model = entry.model, + !native.styleMask.contains(.fullScreen), !isFullScreen(model) + else { return } + let size = native.contentRect(forFrameRect: native.frame).size + guard MainWindowSizeStore.isValid(size) else { return } + entry.normalSize = size + if entries.last === entry { sizes.save(size) } + } + + func isFullScreen(_ model: WindowModel?) -> Bool { + guard let model else { return false } + return fullScreenWindows.contains(ObjectIdentifier(model)) + } + + func performNewWindowCommand(from source: WindowModel?) { + if let source, isFullScreen(source) { + app?.connect(in: source) + } else { + newWindow(from: source) + } + } + + func saveSizeForTermination() { + guard !isTerminating else { return } + if let entry = entries.last { + recordSize(entry) + if let size = entry.normalSize { sizes.save(size) } + } + // Shutdown may close windows in a different order from their focus order. + isTerminating = true + } + isolated deinit { for observer in observers { NotificationCenter.default.removeObserver(observer) } } @@ -71,8 +141,8 @@ final class MainWindowCoordinator { } @discardableResult - func newWindow(showsMetrics: Bool = false) -> UUID? { - request(Request(showsMetrics: showsMetrics)) + func newWindow(from source: WindowModel? = nil, showsMetrics: Bool = false) -> UUID? { + request(Request(showsMetrics: showsMetrics, sourceSize: sourceSize(from: source))) } @discardableResult @@ -82,9 +152,17 @@ final class MainWindowCoordinator { createScene?(existing.key) return existing.key } - let native = entries.first(where: { $0.model === source })?.window - let size = native.flatMap { $0.styleMask.contains(.fullScreen) ? nil : $0.frame.size } - return request(Request(source: source, workspaceID: id, dropPoint: point, sourceSize: size)) + return request( + Request(source: source, workspaceID: id, dropPoint: point, sourceSize: sourceSize(from: source))) + } + + private func sourceSize(from source: WindowModel?) -> CGSize { + let entry = source.flatMap { source in entries.first(where: { $0.model === source }) } ?? entries.last + if let entry { + recordSize(entry) + if let size = entry.normalSize { return size } + } + return sizes.defaultSize } private func request(_ request: Request) -> UUID? { @@ -99,9 +177,10 @@ final class MainWindowCoordinator { guard !model.isClosed else { return } native.tabbingMode = .disallowed entries.removeAll { $0.window == nil || $0.model == nil } - if !entries.contains(where: { $0.window === native && $0.model === model }) { - entries.append(Entry(window: native, model: model)) - } + guard !entries.contains(where: { $0.window === native && $0.model === model }) else { return } + let entry = Entry(window: native, model: model) + entries.append(entry) + if native.styleMask.contains(.fullScreen) { fullScreenWindows.insert(ObjectIdentifier(model)) } app?.registerWindow(model) if let requestID, let request = requests.removeValue(forKey: requestID) { if let workspaceID = request.workspaceID, let source = request.source, @@ -113,17 +192,35 @@ final class MainWindowCoordinator { } else if request.showsMetrics { model.selectedDestination = .metrics } - if let point = request.dropPoint, - let screen = NSScreen.screens.first(where: { $0.frame.contains(point) }) ?? native.screen - { - let size = - request.sourceSize - ?? CGSize( - width: Metrics.Window.defaultWidth, height: Metrics.Window.defaultHeight) - native.setFrame(Self.frame(at: point, size: size, within: screen.visibleFrame), display: true) + applySize(request.sourceSize, to: native, model: model, dropPoint: request.dropPoint) + } + // A restored scene has no pending request. Its system-restored frame + // must not be replaced with another window's shared fallback. + entry.isReady = true + recordSize(entry) + synchronizePaywall() + } + + private func applySize(_ size: CGSize, to native: NSWindow, model: WindowModel, dropPoint: CGPoint?) { + let contentSize = CGSize( + width: max( + size.width, native.contentMinSize.width, Metrics.Window.minWidth(showsSidebar: model.showsSidebar)), + height: max(size.height, native.contentMinSize.height, Metrics.Window.minHeight)) + let frameSize = native.frameRect(forContentRect: CGRect(origin: .zero, size: contentSize)).size + let screen = + dropPoint.flatMap { point in NSScreen.screens.first(where: { $0.frame.contains(point) }) } + ?? native.screen ?? NSScreen.main + var frame = CGRect( + x: native.frame.minX, y: native.frame.maxY - frameSize.height, + width: frameSize.width, height: frameSize.height) + if let screen { + if let dropPoint { + frame = Self.frame(at: dropPoint, size: frameSize, within: screen.visibleFrame) + } else { + frame = Self.fittingFrame(frame, within: screen.visibleFrame) } } - synchronizePaywall() + native.setFrame(frame, display: true) } /// A selected pane consumes the command, even when it was the last one. @@ -143,7 +240,10 @@ final class MainWindowCoordinator { } func close(_ native: NSWindow) { - guard let model = entries.first(where: { $0.window === native })?.model else { return } + guard let entry = entries.first(where: { $0.window === native }), let model = entry.model else { return } + recordSize(entry) + if entries.last === entry, !isTerminating, let size = entry.normalSize { sizes.save(size) } + fullScreenWindows.remove(ObjectIdentifier(model)) entries.removeAll { $0.window === native || $0.window == nil || $0.model == nil } requests = requests.filter { $0.value.source !== model } if paywallOwnerID == ObjectIdentifier(model) { @@ -157,6 +257,10 @@ final class MainWindowCoordinator { private func becameKey(_ native: NSWindow) { guard let index = entries.firstIndex(where: { $0.window === native }) else { return } entries.append(entries.remove(at: index)) + if let entry = entries.last { + recordSize(entry) + if !isTerminating, let size = entry.normalSize { sizes.save(size) } + } synchronizePaywall() } @@ -205,9 +309,16 @@ final class MainWindowCoordinator { static func frame(at point: CGPoint, size: CGSize, within available: CGRect) -> CGRect { let width = min(size.width, available.width) let height = min(size.height, available.height) + return fittingFrame( + CGRect(x: point.x - width / 2, y: point.y - height + 20, width: width, height: height), within: available) + } + + static func fittingFrame(_ frame: CGRect, within available: CGRect) -> CGRect { + let width = min(frame.width, available.width) + let height = min(frame.height, available.height) return CGRect( - x: min(max(point.x - width / 2, available.minX), available.maxX - width), - y: min(max(point.y - height + 20, available.minY), available.maxY - height), + x: min(max(frame.minX, available.minX), available.maxX - width), + y: min(max(frame.maxY - height, available.minY), available.maxY - height), width: width, height: height) } } @@ -247,6 +358,11 @@ final class MainWindowRegistrationView: NSView { } func register() { - if let window { coordinator.register(window, model: model, requestID: requestID) } + guard let window else { return } + // Establish the content coordinate system before reading or applying a + // size. A separate bridge could otherwise register before this style. + if !window.styleMask.contains(.fullSizeContentView) { window.styleMask.insert(.fullSizeContentView) } + if !window.titlebarAppearsTransparent { window.titlebarAppearsTransparent = true } + coordinator.register(window, model: model, requestID: requestID) } } diff --git a/TFM/Platform/MainWindowSizeStore.swift b/TFM/Platform/MainWindowSizeStore.swift new file mode 100644 index 00000000..496aac9d --- /dev/null +++ b/TFM/Platform/MainWindowSizeStore.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Device-local content dimensions. A nil defaults store keeps tests and demos +/// in memory; scene restoration remains responsible for individual window frames. +@MainActor +final class MainWindowSizeStore { + private let defaults: UserDefaults? + private(set) var size: CGSize? + + init(defaults: UserDefaults? = nil) { + self.defaults = defaults + if let values = defaults?.array(forKey: DesktopSettingsKey.mainWindowSize) as? [Double], + values.count == 2 + { + let candidate = CGSize(width: values[0], height: values[1]) + if Self.isValid(candidate) { size = candidate } + } + } + + var defaultSize: CGSize { + size ?? CGSize(width: Metrics.Window.defaultWidth, height: Metrics.Window.defaultHeight) + } + + func save(_ size: CGSize) { + guard Self.isValid(size), size != self.size else { return } + self.size = size + defaults?.set([Double(size.width), Double(size.height)], forKey: DesktopSettingsKey.mainWindowSize) + } + + static func isValid(_ size: CGSize) -> Bool { + size.width.isFinite && size.height.isFinite && size.width > 0 && size.height > 0 + } +} diff --git a/TFMTests/BroadcastInputTests.swift b/TFMTests/BroadcastInputTests.swift index 12167800..fc49c412 100644 --- a/TFMTests/BroadcastInputTests.swift +++ b/TFMTests/BroadcastInputTests.swift @@ -389,6 +389,7 @@ struct BroadcastInputTests { let host = Host(name: "host", host: "host.example") let sshSession = Workspace.sshSession(host: host).focusedPane.sshSession! let shortcuts: [AppShortcutRegistration] = [ + AppShortcutRegistration("n"), AppShortcutRegistration("t"), AppShortcutRegistration("t", modifiers: [.command, .shift]), AppShortcutRegistration("w"), diff --git a/TFMTests/MainWindowSizingTests.swift b/TFMTests/MainWindowSizingTests.swift new file mode 100644 index 00000000..17987f8b --- /dev/null +++ b/TFMTests/MainWindowSizingTests.swift @@ -0,0 +1,282 @@ +import AppKit +import TermindApplication +import TermindDomain +import Testing + +@testable import Termind + +@Suite(.serialized) +@MainActor +struct MainWindowSizingTests { + @Test + func storedSizesRoundTripAndInvalidValuesUseTheDefault() throws { + try withDefaults { defaults in + let fallback = CGSize(width: 1180, height: 740) + #expect(MainWindowSizeStore(defaults: defaults).defaultSize == fallback) + for value in [[], [900], [900, 600, 1], [-1, 600], [900, 0]] as [[Double]] { + defaults.set(value, forKey: DesktopSettingsKey.mainWindowSize) + #expect(MainWindowSizeStore(defaults: defaults).defaultSize == fallback) + } + defaults.set("invalid", forKey: DesktopSettingsKey.mainWindowSize) + let store = MainWindowSizeStore(defaults: defaults) + let size = CGSize(width: 923.5, height: 617.5) + store.save(size) + store.save(CGSize(width: CGFloat.infinity, height: 600)) + store.save(CGSize(width: 900, height: CGFloat.nan)) + #expect(MainWindowSizeStore(defaults: defaults).size == size) + let isolated = MainWindowSizeStore() + isolated.save(CGSize(width: 800, height: 500)) + #expect(MainWindowSizeStore(defaults: defaults).size == size) + } + } + + @Test + func requestsCaptureTheirOwnSourceAndRepeatedCopiesDoNotAccumulateTitlebarHeight() throws { + let app = AppModel(hosts: HostLibraryModel()) + app.mainWindows.createScene = { _ in } + let firstModel = WindowModel() + let secondModel = WindowModel() + let first = nativeWindow(size: CGSize(width: 900, height: 600)) + let second = nativeWindow(size: CGSize(width: 1000, height: 650)) + var natives = [first, second] + var models = [firstModel, secondModel] + defer { for window in natives { window.close() } } + app.mainWindows.register(first, model: firstModel, requestID: nil) + app.mainWindows.register(second, model: secondModel, requestID: nil) + post(NSWindow.didBecomeKeyNotification, first) + let firstRequest = try #require(app.mainWindows.newWindow(from: firstModel)) + let secondRequest = try #require(app.mainWindows.newWindow(from: secondModel)) + first.setContentSize(CGSize(width: 960, height: 660)) + + for (request, expected) in [ + (secondRequest, CGSize(width: 1000, height: 650)), + (firstRequest, CGSize(width: 900, height: 600)), + ] { + let native = nativeWindow() + let model = WindowModel() + natives.append(native) + models.append(model) + app.mainWindows.register(native, model: model, requestID: request) + #expect(native.frame.size == expected) + } + for _ in 0..<4 { + let request = try #require(app.mainWindows.newWindow(from: models.last)) + let native = nativeWindow() + let model = WindowModel() + natives.append(native) + models.append(model) + app.mainWindows.register(native, model: model, requestID: request) + #expect(native.frame.size == CGSize(width: 900, height: 600)) + } + let last = try #require(natives.last) + let model = try #require(models.last) + last.setContentSize(CGSize(width: 940, height: 640)) + app.mainWindows.register(last, model: model, requestID: firstRequest) + #expect(last.frame.size == CGSize(width: 940, height: 640)) + } + + @Test + func registrationConfiguresFullSizeContentBeforeApplyingTheSourceSize() throws { + let app = AppModel(hosts: HostLibraryModel()) + app.mainWindows.createScene = { _ in } + let source = nativeWindow(size: CGSize(width: 920, height: 620)) + let sourceModel = WindowModel() + let targetModel = WindowModel() + let target = NSWindow( + contentRect: CGRect(x: 100, y: 150, width: 800, height: 600), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + target.isReleasedWhenClosed = false + defer { + source.close() + target.close() + } + app.mainWindows.register(source, model: sourceModel, requestID: nil) + let request = try #require(app.mainWindows.newWindow(from: sourceModel)) + target.contentView = MainWindowRegistrationView( + coordinator: app.mainWindows, model: targetModel, requestID: request) + #expect(target.styleMask.contains(.fullSizeContentView)) + #expect(target.titlebarAppearsTransparent) + #expect(target.frame.size == source.frame.size) + } + + @Test + func closingAllWindowsAndDockReopeningUsesTheSavedSize() throws { + try withDefaults { defaults in + let app = AppModel(hosts: HostLibraryModel(), windowSizeStore: MainWindowSizeStore(defaults: defaults)) + let original = nativeWindow() + let model = WindowModel() + app.mainWindows.register(original, model: model, requestID: nil) + let size = CGSize(width: 950, height: 620) + original.setContentSize(size) + post(NSWindow.didEndLiveResizeNotification, original) + original.close() + var request: UUID? + app.mainWindows.createScene = { request = $0 } + #expect(!app.mainWindows.handleReopen()) + let reopened = nativeWindow() + defer { reopened.close() } + let reopenedModel = WindowModel() + let reopenRequest = try #require(request) + app.mainWindows.register(reopened, model: reopenedModel, requestID: reopenRequest) + #expect(reopened.frame.size == size) + #expect(reopenedModel.workspaces.isEmpty) + #expect(MainWindowSizeStore(defaults: defaults).defaultSize == size) + } + } + + @Test + func relaunchDefaultDoesNotOverrideIndividualRestoredFrames() throws { + try withDefaults { defaults in + MainWindowSizeStore(defaults: defaults).save(CGSize(width: 1050, height: 700)) + let app = AppModel(hosts: HostLibraryModel(), windowSizeStore: MainWindowSizeStore(defaults: defaults)) + #expect(app.windowSizeStore.defaultSize == CGSize(width: 1050, height: 700)) + let first = nativeWindow(size: CGSize(width: 910, height: 610)) + let second = nativeWindow(size: CGSize(width: 980, height: 680)) + let firstModel = WindowModel() + let secondModel = WindowModel() + defer { + first.close() + second.close() + } + let firstFrame = first.frame + let secondFrame = second.frame + app.mainWindows.register(first, model: firstModel, requestID: nil) + app.mainWindows.register(second, model: secondModel, requestID: UUID()) + #expect(first.frame == firstFrame) + #expect(second.frame == secondFrame) + #expect(firstModel.workspaces.isEmpty && secondModel.workspaces.isEmpty) + } + } + + @Test + func fullScreenCommandConnectsWhileExplicitNewWindowUsesTheOrdinarySize() throws { + let app = AppModel(hosts: HostLibraryModel()) + var requests: [UUID] = [] + app.mainWindows.createScene = { requests.append($0) } + let model = WindowModel() + let normalSize = CGSize(width: 900, height: 600) + let native = nativeWindow(size: normalSize) + let destination = nativeWindow() + let destinationModel = WindowModel() + defer { + native.close() + destination.close() + } + app.mainWindows.register(native, model: model, requestID: nil) + post(NSWindow.willEnterFullScreenNotification, native) + native.setContentSize(CGSize(width: 1300, height: 800)) + post(NSWindow.didEnterFullScreenNotification, native) + post(NSWindow.didResizeNotification, native) + app.mainWindows.performNewWindowCommand(from: model) + #expect(app.mainWindows.isFullScreen(model)) + #expect(model.hostChooser == .connect) + #expect(model.workspaces.isEmpty) + #expect(requests.isEmpty) + #expect(app.windowSizeStore.size == normalSize) + + app.commands.perform(.newWindow, app: app, window: model) + let newWindowRequest = try #require(requests.first) + app.mainWindows.register(destination, model: destinationModel, requestID: newWindowRequest) + #expect(destination.frame.size == normalSize) + + post(NSWindow.willExitFullScreenNotification, native) + native.setContentSize(CGSize(width: 1100, height: 700)) + post(NSWindow.didResizeNotification, native) + #expect(app.windowSizeStore.size == normalSize) + native.setContentSize(normalSize) + post(NSWindow.didExitFullScreenNotification, native) + model.hostChooser = nil + app.mainWindows.performNewWindowCommand(from: model) + #expect(!app.mainWindows.isFullScreen(model)) + #expect(model.hostChooser == nil) + #expect(requests.count == 2) + } + + @Test + func backgroundAndAuxiliaryWindowsCannotReplaceTheLastUsedSizeDuringQuit() { + let app = AppModel(hosts: HostLibraryModel()) + let first = nativeWindow(size: CGSize(width: 900, height: 600)) + let second = nativeWindow(size: CGSize(width: 1000, height: 650)) + let auxiliary = nativeWindow(size: CGSize(width: 560, height: 240)) + let firstModel = WindowModel() + let secondModel = WindowModel() + defer { + first.close() + second.close() + auxiliary.close() + } + app.mainWindows.register(first, model: firstModel, requestID: nil) + app.mainWindows.register(second, model: secondModel, requestID: nil) + post(NSWindow.didBecomeKeyNotification, first) + second.setContentSize(CGSize(width: 1100, height: 700)) + post(NSWindow.didResizeNotification, second) + post(NSWindow.didBecomeKeyNotification, auxiliary) + post(NSWindow.didResizeNotification, auxiliary) + #expect(app.windowSizeStore.size == first.frame.size) + app.mainWindows.saveSizeForTermination() + let saved = app.windowSizeStore.size + first.close() + post(NSWindow.didBecomeKeyNotification, second) + second.close() + #expect(app.windowSizeStore.size == saved) + } + + @Test + func workspaceMovesInheritSizeAndNewWindowsRespectTheirContentMinimum() throws { + let app = AppModel(hosts: HostLibraryModel()) + app.mainWindows.createScene = { _ in } + let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example")) + let model = WindowModel(workspaces: [workspace]) + let native = nativeWindow(size: CGSize(width: 910, height: 610)) + let destination = nativeWindow() + let target = WindowModel() + defer { + native.close() + destination.close() + } + app.mainWindows.register(native, model: model, requestID: nil) + let request = try #require(app.mainWindows.moveWorkspace(workspace.id, from: model)) + app.mainWindows.register(destination, model: target, requestID: request) + #expect(destination.frame.size == native.frame.size) + #expect(target.selectedWorkspace === workspace) + + native.setContentSize(CGSize(width: 400, height: 440)) + let smallRequest = try #require(app.mainWindows.newWindow(from: model)) + let constrained = nativeWindow() + let constrainedModel = WindowModel() + defer { constrained.close() } + constrained.contentMinSize = CGSize(width: 700, height: 500) + app.mainWindows.register(constrained, model: constrainedModel, requestID: smallRequest) + #expect(constrained.frame.width >= 700) + #expect(constrained.frame.height >= 500) + } + + @Test + func fittingToASmallerScreenRetainsTheTopEdgeWhenPossible() { + let screen = CGRect(x: -1200, y: 100, width: 1200, height: 800) + let oversized = CGRect(x: 100, y: 0, width: 1600, height: 1000) + #expect(MainWindowCoordinator.fittingFrame(oversized, within: screen) == screen) + let fitting = CGRect(x: -1100, y: 200, width: 900, height: 600) + #expect(MainWindowCoordinator.fittingFrame(fitting, within: screen) == fitting) + } + + private func nativeWindow(size: CGSize = CGSize(width: 800, height: 600)) -> NSWindow { + let window = NSWindow( + contentRect: CGRect(origin: CGPoint(x: 100, y: 150), size: size), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.animationBehavior = .none + return window + } + + private func post(_ name: Notification.Name, _ window: NSWindow) { + NotificationCenter.default.post(name: name, object: window) + } + + private func withDefaults(_ body: (UserDefaults) throws -> Void) throws { + let name = "MainWindowSizingTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + try body(defaults) + } +}