Files
Termind/TFM/Platform/MainWindowCoordinator.swift

633 lines
26 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AppKit
import SwiftUI
import TermindPresentation
/// Process-owned scene requests; live workspaces stay in their source until the
/// destination has a native window. Scene restoration never replays a move.
@MainActor
@Observable
final class MainWindowCoordinator {
struct Layout {
/// Ordinary AppKit content coordinates, including the full-size titlebar.
let size: CGSize
let sidebarWidth: CGFloat
let showsSidebar: Bool
let isFullScreen: Bool
let sourceScreenPoint: CGPoint?
}
private enum Move: Equatable {
case workspace(UUID)
case pane(UUID)
}
private final class Entry {
weak var window: NSWindow?
weak var model: WindowModel?
var normalSize: CGSize?
var isTransitioningFullScreen = false
var pendingFullScreen = false
var fullScreenToggleScheduled = false
var initialSidebarWidth: CGFloat?
var isReady = false
init(window: NSWindow, model: WindowModel) {
self.window = window
self.model = model
}
}
private final class Request {
weak var source: WindowModel?
let move: Move?
let closedRecordID: UUID?
let showsMetrics: Bool
let metricsHostID: UUID?
let layout: Layout
let screenPoint: CGPoint?
init(
source: WindowModel? = nil, move: Move? = nil, closedRecordID: UUID? = nil,
showsMetrics: Bool = false, metricsHostID: UUID? = nil, layout: Layout, screenPoint: CGPoint? = nil
) {
self.source = source
self.move = move
self.closedRecordID = closedRecordID
self.showsMetrics = showsMetrics
self.metricsHostID = metricsHostID
self.layout = layout
self.screenPoint = screenPoint
}
func takeWorkspace() -> Workspace? {
guard let source else { return nil }
switch move {
case .workspace(let id): return source.takeWorkspaceForMove(id)
case .pane(let id): return source.takePaneForMove(id)
case nil: return nil
}
}
}
@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 createScene: ((UUID) -> Void)?
@ObservationIgnored private var isTerminating = false
@ObservationIgnored private var transfersWindow: NSWindow?
private(set) var isTransfersWindowOpen = false
private(set) var isTransfersWindowKey = false
private(set) var paywallOwnerID: ObjectIdentifier?
private var fullScreenWindows: Set<ObjectIdentifier> = []
func toggleTransfers() {
if isTransfersWindowOpen {
closeTransfers()
} else {
showTransfers()
}
}
func closeTransfers() {
transfersWindow?.performClose(nil)
}
func showTransfers() {
guard !isTerminating, let app else { return }
if let transfersWindow {
transfersWindow.deminiaturize(nil)
transfersWindow.makeKeyAndOrderFront(nil)
} else {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: Metrics.TransferCenter.width, height: 400),
styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.title = String(localized: AppStrings.SFTP.transfers)
window.contentViewController = NSHostingController(
rootView: TransferCenterPopover(
coordinator: app.sftpTransfers, hosts: app.hostLibrary,
dismiss: { [weak window] in window?.close() }))
transfersWindow = window
window.center()
window.makeKeyAndOrderFront(nil)
}
isTransfersWindowOpen = true
isTransfersWindowKey = transfersWindow?.isKeyWindow == true
NSApp.activate(ignoringOtherApps: true)
}
init(app: AppModel, sizes: MainWindowSizeStore) {
self.app = app
self.sizes = sizes
observers.append(
NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
for entry in self.entries where entry.window?.isKeyWindow == true {
entry.model?.selectedWorkspace?.markCommandsRead()
}
}
})
for name in [
NSWindow.willCloseNotification, NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification,
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 {
self?.handle(name, for: window)
}
})
}
}
private func handle(_ notification: Notification.Name, for native: NSWindow) {
if native === transfersWindow {
switch notification {
case NSWindow.willCloseNotification:
isTransfersWindowOpen = false
isTransfersWindowKey = false
case NSWindow.didBecomeKeyNotification:
isTransfersWindowKey = true
case NSWindow.didResignKeyNotification:
isTransfersWindowKey = false
default:
break
}
return
}
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
entry.pendingFullScreen = false
fullScreenWindows.insert(ObjectIdentifier(model))
if let width = model.sidebar.measuredWidth { recordSidebarWidth(width, for: model) }
case NSWindow.willExitFullScreenNotification:
entry.isTransitioningFullScreen = true
case NSWindow.didExitFullScreenNotification:
entry.isTransitioningFullScreen = false
fullScreenWindows.remove(ObjectIdentifier(model))
recordSize(entry)
if let width = model.sidebar.measuredWidth { recordSidebarWidth(width, for: model) }
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 { saveFallback(from: entry) }
}
private func saveFallback(from entry: Entry) {
if let size = entry.normalSize { sizes.save(size) }
if let model = entry.model {
sizes.saveSidebar(width: model.sidebar.preferredWidth, isPresented: model.showsSidebar)
}
}
func isFullScreen(_ model: WindowModel?) -> Bool {
guard let model else { return false }
return fullScreenWindows.contains(ObjectIdentifier(model))
}
func performNewWindowCommand(from source: WindowModel?) {
newWindow(from: source)
}
func saveSizeForTermination() {
guard !isTerminating else { return }
if let entry = entries.last {
recordSize(entry)
saveFallback(from: entry)
}
// Shutdown may close windows in a different order from their focus order.
isTerminating = true
transfersWindow?.close()
transfersWindow = nil
}
isolated deinit {
for observer in observers { NotificationCenter.default.removeObserver(observer) }
}
func registerWindowCreation(_ create: @escaping (UUID) -> Void) {
createScene = create
}
func frame(for model: WindowModel) -> CGRect? {
entries.first(where: { $0.model === model })?.window?.frame
}
@discardableResult
func newWindow(from source: WindowModel? = nil, showsMetrics: Bool = false) -> UUID? {
request(Request(showsMetrics: showsMetrics, layout: sourceLayout(from: source)))
}
@discardableResult
func reopenClosedWindow(_ id: UUID, from source: WindowModel?) -> UUID? {
if let pending = requests.first(where: { $0.value.closedRecordID == id }) { return pending.key }
return request(Request(closedRecordID: id, layout: sourceLayout(from: source)))
}
@discardableResult
func moveWorkspace(_ id: UUID, from source: WindowModel, at screenPoint: CGPoint? = nil) -> UUID? {
guard !source.isClosed, source.workspaces.contains(where: { $0.id == id }) else { return nil }
return move(.workspace(id), from: source, at: screenPoint)
}
@discardableResult
func movePane(_ id: UUID, from source: WindowModel, at screenPoint: CGPoint? = nil) -> UUID? {
guard !source.isClosed, source.pane(id)?.isCombinable == true else { return nil }
return move(.pane(id), from: source, at: screenPoint)
}
private func move(_ move: Move, from source: WindowModel, at screenPoint: CGPoint?) -> UUID? {
if let existing = requests.first(where: { $0.value.source === source && $0.value.move == move }) {
return existing.key
}
return request(
Request(source: source, move: move, layout: sourceLayout(from: source), screenPoint: screenPoint))
}
private func sourceLayout(from source: WindowModel?) -> Layout {
let entry =
source.flatMap { source in entries.first(where: { $0.model === source }) }
?? (source == nil ? entries.last : nil)
if let entry { recordSize(entry) }
let model = source ?? entry?.model
let sourceFrame = entry?.window?.frame
return Layout(
size: entry?.normalSize ?? sizes.defaultSize,
sidebarWidth: model.map {
$0.showsSidebar ? ($0.sidebar.observedWidth ?? $0.sidebar.preferredWidth) : $0.sidebar.preferredWidth
}
?? sizes.defaultSidebarWidth,
showsSidebar: model?.showsSidebar ?? sizes.defaultShowsSidebar,
isFullScreen: model.map { isFullScreen($0) } ?? false,
sourceScreenPoint: sourceFrame.map { CGPoint(x: $0.midX, y: $0.midY) })
}
/// Called while constructing the scene, before RootView's first layout.
func configure(_ model: WindowModel, for requestID: UUID?) {
guard let requestID, let layout = requests[requestID]?.layout else { return }
configure(model, with: layout)
}
func requestedLayout(for requestID: UUID) -> Layout? {
requests[requestID]?.layout
}
func recordSidebarWidth(_ width: CGFloat, for model: WindowModel) {
model.sidebar.measuredWidth = width
guard MainWindowSizeStore.isValidSidebarWidth(width), model.showsSidebar,
let entry = entries.first(where: { $0.model === model }), entry.isReady
else { return }
guard !entry.pendingFullScreen, !entry.isTransitioningFullScreen else { return }
if let requestedWidth = entry.initialSidebarWidth, let native = entry.window {
let availableWidth = native.contentRect(forFrameRect: native.frame).width
let expectedWidth = min(requestedWidth, Metrics.Window.sidebarMaxWidth(for: availableWidth))
guard abs(width - expectedWidth) < 3 else { return }
entry.initialSidebarWidth = nil
}
model.sidebar.observedWidth = width
model.sidebar.preferredWidth = width
if entries.last === entry { saveFallback(from: entry) }
}
func recordSidebarPresentation(for model: WindowModel) {
guard let entry = entries.first(where: { $0.model === model }), entry.isReady,
entries.last === entry
else { return }
saveFallback(from: entry)
}
private func request(_ request: Request) -> UUID? {
guard let createScene else { return nil }
let id = UUID()
requests[id] = request
createScene(id)
return id
}
func register(_ native: NSWindow, model: WindowModel, requestID: UUID?) {
guard !model.isClosed else { return }
native.tabbingMode = .disallowed
entries.removeAll { $0.window == nil || $0.model == nil }
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) {
configure(model, with: request.layout)
// A tear-off should appear at the release point immediately, without
// another document-opening animation after the drag completes.
if request.screenPoint != nil { native.animationBehavior = .none }
if let closedRecordID = request.closedRecordID {
app?.restoreClosedRecord(closedRecordID, into: model)
} else if let workspace = request.takeWorkspace() {
model.open(workspace)
model.refreshSessionContext()
model.reconcileSidebarMode()
} else if request.showsMetrics {
showMetrics(hostID: request.metricsHostID, in: model)
}
applySize(
request.layout.size, to: native, model: model, at: request.screenPoint,
preferredScreenPoint: request.layout.isFullScreen ? request.layout.sourceScreenPoint : nil)
entry.initialSidebarWidth = request.layout.sidebarWidth
entry.pendingFullScreen = request.layout.isFullScreen
if request.move != nil {
// A transferred workspace can make the first split-view layout
// use the system width. Rebuild after the transfer and sizing,
// keeping the normal resizable column limits.
model.sidebar.initialLayoutGeneration += 1
}
}
// A restored scene has no pending request. Its system-restored frame
// must not be replaced with another window's shared fallback.
entry.isReady = true
if pendingActivationID == model.id { activate(model) }
if isFocused(model) { model.selectedWorkspace?.markCommandsRead() }
recordSize(entry)
if let width = model.sidebar.measuredWidth { recordSidebarWidth(width, for: model) }
synchronizePaywall()
activatePendingFullScreen(entry)
}
private func configure(_ model: WindowModel, with layout: Layout) {
let changed =
model.showsSidebar != layout.showsSidebar
|| model.sidebar.preferredWidth != layout.sidebarWidth
model.showsSidebar = layout.showsSidebar
model.sidebar.preferredWidth = layout.sidebarWidth
if changed { model.sidebar.initialLayoutGeneration += 1 }
}
private func applySize(
_ size: CGSize, to native: NSWindow, model: WindowModel, at screenPoint: CGPoint?,
preferredScreenPoint: 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 =
(screenPoint ?? preferredScreenPoint).flatMap { point in NSScreen.screens.first { $0.frame.contains(point) }
}
?? native.screen ?? NSScreen.main
var frame = CGRect(
x: screenPoint.map { $0.x - frameSize.width / 2 } ?? native.frame.minX,
y: (screenPoint?.y ?? native.frame.maxY) - frameSize.height,
width: frameSize.width, height: frameSize.height)
if let screen {
frame = Self.fittingFrame(frame, within: screen.visibleFrame)
}
native.setFrame(frame, display: true)
}
private func activatePendingFullScreen(_ entry: Entry) {
guard entry.pendingFullScreen, !entry.fullScreenToggleScheduled,
let native = entry.window, native.isKeyWindow
else { return }
entry.fullScreenToggleScheduled = true
Task { @MainActor [weak self, weak native] in
guard let self, let native,
self.entries.contains(where: { $0 === entry })
else { return }
entry.fullScreenToggleScheduled = false
guard entry.pendingFullScreen, native.isKeyWindow else { return }
if native.styleMask.contains(.fullScreen) {
entry.pendingFullScreen = false
} else {
native.toggleFullScreen(nil)
}
}
}
/// A selected pane consumes the command, even when it was the last one.
/// Window fallback uses the native dismissal path so confirmation can veto it.
func closeSelectionOrWindow(in model: WindowModel) {
guard !model.isClosed else { return }
if model.selectedWorkspace != nil {
model.closeSelected()
} else {
closeWindow(in: model)
}
}
func closeWindow(in model: WindowModel) {
guard !model.isClosed else { return }
entries.first(where: { $0.model === model })?.window?.performClose(nil)
}
func closeAllWindows() {
// Closing a window removes its entry. Snapshot first and keep native confirmation in the loop.
let windows = entries.compactMap { entry in
entry.model?.isClosed == false ? entry.window : nil
}
for window in windows { window.performClose(nil) }
}
func close(_ native: NSWindow) {
guard let entry = entries.first(where: { $0.window === native }), let model = entry.model else { return }
recordSize(entry)
if entries.last === entry, !isTerminating { saveFallback(from: entry) }
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) {
paywallOwnerID = nil
app?.proAccess.isPaywallPresented = false
}
app?.unregisterWindow(model)
model.closeForWindow()
}
private func becameKey(_ native: NSWindow) {
if NSApp.isActive {
entries.first(where: { $0.window === native })?.model?.selectedWorkspace?.markCommandsRead()
}
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 { saveFallback(from: entry) }
activatePendingFullScreen(entry)
}
synchronizePaywall()
}
private var pendingActivationID: UUID?
var mostRecentWindow: WindowModel? {
entries.reversed().first(where: { $0.window != nil && $0.model?.isClosed == false })?.model
}
func isFocused(_ model: WindowModel) -> Bool {
NSApp.isActive && entries.contains { $0.model === model && $0.window?.isKeyWindow == true }
}
func activate(_ model: WindowModel) {
guard let native = entries.first(where: { $0.model === model })?.window else {
pendingActivationID = model.id
return
}
pendingActivationID = nil
native.deminiaturize(nil)
native.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
model.selectedWorkspace?.markCommandsRead()
}
@discardableResult
func openExisting(showsMetrics: Bool = false) -> Bool {
guard let entry = entries.reversed().first(where: { $0.window != nil && $0.model?.isClosed == false }),
let native = entry.window, let model = entry.model
else { return false }
if showsMetrics { model.selectedDestination = .metrics }
native.deminiaturize(nil)
native.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return true
}
func openMetrics(hostID: UUID? = nil) {
if let model = mostRecentWindow {
showMetrics(hostID: hostID, in: model)
activate(model)
} else if request(
Request(showsMetrics: true, metricsHostID: hostID, layout: sourceLayout(from: nil))) != nil
{
NSApp.activate(ignoringOtherApps: true)
}
}
private func showMetrics(hostID: UUID?, in model: WindowModel) {
model.selectedDestination = .metrics
if let hostID {
model.metricsHostID = app?.serverMetrics.servers.contains(where: { $0.id == hostID }) == true ? hostID : nil
}
}
/// Only a cold launch without a scene factory needs SwiftUI's default reopening.
func handleReopen() -> Bool {
Self.handleReopen(
openExistingWindow: { openExisting() },
createWindow: createScene == nil ? nil : { self.newWindow() })
}
static func handleReopen(openExistingWindow: () -> Bool, createWindow: (() -> Void)?) -> Bool {
if openExistingWindow() { return false }
guard let createWindow else { return true }
createWindow()
return false
}
func synchronizePaywall() {
guard app?.proAccess.isPaywallPresented == true else {
paywallOwnerID = nil
return
}
guard paywallOwnerID == nil else { return }
let entry =
entries.first(where: { $0.window?.isKeyWindow == true })
?? entries.reversed().first(where: { $0.window != nil && $0.model?.isClosed == false })
paywallOwnerID = entry?.model.map(ObjectIdentifier.init)
}
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(frame.minX, available.minX), available.maxX - width),
y: min(max(frame.maxY - height, available.minY), available.maxY - height),
width: width, height: height)
}
}
struct MainWindowRegistration: NSViewRepresentable {
let coordinator: MainWindowCoordinator
let model: WindowModel
let requestID: UUID?
func makeNSView(context: Context) -> MainWindowRegistrationView {
MainWindowRegistrationView(coordinator: coordinator, model: model, requestID: requestID)
}
func updateNSView(_ view: MainWindowRegistrationView, context: Context) {
view.register()
}
}
final class MainWindowRegistrationView: NSView {
private let coordinator: MainWindowCoordinator
private let model: WindowModel
private let requestID: UUID?
private let closeDelegate: WorkspaceWindowCloseDelegate
private var documentEditedObservation: NSKeyValueObservation?
init(coordinator: MainWindowCoordinator, model: WindowModel, requestID: UUID?) {
self.coordinator = coordinator
self.model = model
self.requestID = requestID
closeDelegate = WorkspaceWindowCloseDelegate(model: model)
super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
closeDelegate.attach(to: window)
documentEditedObservation = nil
// Workspaces are not documents; suppress edited decoration without
// changing the native window-close confirmation.
documentEditedObservation = window?.observe(\.isDocumentEdited, options: [.initial, .new]) { window, change in
guard change.newValue == true else { return }
MainActor.assumeIsolated { window.isDocumentEdited = false }
}
register()
}
func register() {
guard let window else { return }
closeDelegate.attach(to: window)
// 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)
}
}