keep window size

This commit is contained in:
Kaede Akino committed 2026-09-18 17:26:38 +08:00
1 parent 67574a7fed
commit 9b41a9a7aa
12 files changed
+509 -68

No files matched your search

+12
View File
@@ -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
+12
View File
@@ -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.
+5 -2
View File
@@ -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
+2
View File
@@ -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"
-30
View File
@@ -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
}
}
}
+4 -2
View File
@@ -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
+10 -2
View File
@@ -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()
@@ -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:
+147 -31
View File
@@ -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<ObjectIdentifier> = []
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)
}
}
+33
View File
@@ -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
}
}
+1
View File
@@ -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"),
+282
View File
@@ -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)
}
}