Files
Termind/TFM/Platform/MainWindowCoordinator.swift
T
2026-09-19 12:30:16 +08:00

360 lines
14 KiB
Swift

import AppKit
import SwiftUI
/// 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 {
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
}
}
private final class Request {
weak var source: WindowModel?
let workspaceID: UUID?
let showsMetrics: Bool
/// 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, sourceSize: CGSize
) {
self.source = source
self.workspaceID = workspaceID
self.showsMetrics = showsMetrics
self.sourceSize = sourceSize
}
}
@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
private(set) var paywallOwnerID: ObjectIdentifier?
private var fullScreenWindows: Set<ObjectIdentifier> = []
init(app: AppModel, sizes: MainWindowSizeStore) {
self.app = app
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 {
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) }
}
func registerWindowCreation(_ create: @escaping (UUID) -> Void) {
createScene = create
}
@discardableResult
func newWindow(from source: WindowModel? = nil, showsMetrics: Bool = false) -> UUID? {
request(Request(showsMetrics: showsMetrics, sourceSize: sourceSize(from: source)))
}
@discardableResult
func moveWorkspace(_ id: UUID, from source: WindowModel) -> UUID? {
guard !source.isClosed, source.workspaces.contains(where: { $0.id == id }) else { return nil }
if let existing = requests.first(where: { $0.value.source === source && $0.value.workspaceID == id }) {
createScene?(existing.key)
return existing.key
}
return request(
Request(source: source, workspaceID: id, 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? {
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) {
if let workspaceID = request.workspaceID, let source = request.source,
let workspace = source.takeWorkspaceForMove(workspaceID)
{
model.open(workspace)
model.refreshSessionContext()
model.reconcileSidebarMode()
} else if request.showsMetrics {
model.selectedDestination = .metrics
}
applySize(request.sourceSize, to: native, model: model)
}
// 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) {
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 = 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 {
frame = Self.fittingFrame(frame, within: screen.visibleFrame)
}
native.setFrame(frame, display: true)
}
/// 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 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, 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) {
paywallOwnerID = nil
app?.proAccess.isPaywallPresented = false
}
app?.unregisterWindow(model)
model.closeForWindow()
}
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()
}
@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() {
if !openExisting(showsMetrics: true) { newWindow(showsMetrics: true) }
}
/// 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 var documentEditedObservation: NSKeyValueObservation?
init(coordinator: MainWindowCoordinator, model: WindowModel, requestID: UUID?) {
self.coordinator = coordinator
self.model = model
self.requestID = requestID
super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
documentEditedObservation = nil
// SwiftUI marks a window with dismissal confirmation as edited. Workspaces
// are not documents; suppress that decoration without changing dismissal.
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 }
// 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)
}
}