keep window size
This commit is contained in:
1 parent
67574a7fed
commit
9b41a9a7aa
12 files changed
+509
-68
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user