From 9c898b0ca61c090e5e327f750f6c73e28475e10b Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 12 Jun 2026 19:30:19 +0800
Subject: [PATCH] Fix space filter visual and close verification
---
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 784 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 files changed, 770 insertions(+), 14 deletions(-)
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index fa79190..947b96e 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -5,13 +5,23 @@
@MainActor
final class QuickSwitchSessionController {
static let overlayTitle = "Aligner Quick Switch"
+ private static let closeRequestStillPresentMessage = "已发送关闭请求,但窗口仍在列表中。"
+ private static let closeTargetNotFoundMessage = "没有找到这个窗口的关闭控件,已刷新列表。"
+ private static let closeUnsupportedMessage = "这个窗口没有可用的关闭按钮。"
+ private static let closeFailedMessage = "关闭请求没有被系统接受。"
+ private static let closeVerificationRetryDelays: [TimeInterval] = [0.35, 0.70, 1.20]
private let view = QuickSwitchRootView()
private let snapshotLoader: any QuickSwitchSnapshotLoading
private let screenshotProvider: any ScreenshotProviderProtocol
private let windowActivationService: any WindowActivationServiceProtocol
+ private let spaceActivationService: any SpaceActivationServiceProtocol
+ private let windowCloseService: any WindowCloseServiceProtocol
private let systemCriticalWindowDetector: any SystemCriticalWindowDetecting
private let disableScreenshotRefresh: Bool
+ private var waterfallViewMode: QuickSwitchWaterfallViewMode
+ private var closeConfirmationRequired: Bool
+ private let onCloseConfirmationDisabled: (() -> Void)?
private let debugOverlayWidth: CGFloat?
private let debugKeySequence: [String]
private let debugMouseSequence: [String]
@@ -22,7 +32,13 @@
debugOverlayWidth: debugOverlayWidth
)
private var currentSnapshot: QuickSwitchSnapshot?
+ private var sourceViewModel: QuickSwitchViewModel?
private var currentViewModel: QuickSwitchViewModel?
+ private var lockedSpaceFilterID: UInt64?
+ private var selectionBeforeSpaceFilter: QuickSwitchSelection?
+ private var lastSpaceFilterAction: String?
+ private var pendingLockedSpaceUnlockSpaceID: UInt64?
+ private var pendingLockedSpaceUnlockTask: Task<Void, Never>?
private var lastSnapshotError: String?
private var snapshotTask: Task<Void, Never>?
private var screenshotTask: Task<Void, Never>?
@@ -40,6 +56,19 @@
private var lastActivationWindowID: UInt32?
private var lastActivationResult: WindowActivationResult?
private var lastActivationError: String?
+ private var lastSpaceActivationSpaceID: UInt64?
+ private var lastSpaceActivationDidRequestFocus: Bool?
+ private var lastSpaceActivationDisplayIdentifier: String?
+ private var lastSpaceActivationPreviousCurrentSpaceID: UInt64?
+ private var lastSpaceActivationError: String?
+ private var lastCloseTargetKind: String?
+ private var lastCloseAppGroupIndex: Int?
+ private var lastCloseAppName: String?
+ private var lastCloseWindowID: UInt32?
+ private var lastCloseResult: WindowCloseResult?
+ private var lastCloseError: String?
+ private var pendingCloseVerification: PendingCloseVerification?
+ private var suppressedCloseTargets: [SuppressedCloseTarget] = []
private var lastDismissReason: DismissReason?
private var lastSystemCriticalAction: String?
private var lastSystemCriticalMonitorSource: String?
@@ -57,6 +86,27 @@
private var lifecycleResidualOverlayWindows = 0
private var lifecycleResidualVisibleOverlayWindows = 0
var onSnapshotUpdated: (() -> Void)?
+ var onOpenSettings: (() -> Void)?
+
+ private struct PendingCloseVerification {
+ let request: QuickSwitchCloseRequest
+ let generation: Int
+ let attempt: Int
+ }
+
+ private enum SuppressedCloseTarget: Equatable {
+ case app(AlignerApp)
+ case window(UInt32)
+
+ init(_ request: QuickSwitchCloseRequest) {
+ switch request {
+ case .app:
+ self = .app(request.app)
+ case .window:
+ self = .window(request.windowID ?? 0)
+ }
+ }
+ }
init(
snapshotLoader: any QuickSwitchSnapshotLoading = LiveQuickSwitchSnapshotLoader(),
@@ -66,8 +116,15 @@
windowActivationService: any WindowActivationServiceProtocol = CGWindowAXWindowService(
spaceIDsByWindowIDProvider: { _ in [:] }
),
+ spaceActivationService: any SpaceActivationServiceProtocol = PrivateSpaceActivationService(),
+ windowCloseService: any WindowCloseServiceProtocol = CGWindowAXWindowService(
+ spaceIDsByWindowIDProvider: { _ in [:] }
+ ),
systemCriticalWindowDetector: any SystemCriticalWindowDetecting = CGWindowSystemCriticalWindowDetector(),
disableScreenshotRefresh: Bool = false,
+ waterfallViewMode: QuickSwitchWaterfallViewMode = .verticalColumns,
+ closeConfirmationRequired: Bool = true,
+ onCloseConfirmationDisabled: (() -> Void)? = nil,
debugHoveredAppGroupIndex: Int? = nil,
debugOverlayWidth: CGFloat? = nil,
debugKeySequence: [String] = [],
@@ -77,8 +134,13 @@
self.snapshotLoader = snapshotLoader
self.screenshotProvider = screenshotProvider
self.windowActivationService = windowActivationService
+ self.spaceActivationService = spaceActivationService
+ self.windowCloseService = windowCloseService
self.systemCriticalWindowDetector = systemCriticalWindowDetector
self.disableScreenshotRefresh = disableScreenshotRefresh
+ self.waterfallViewMode = waterfallViewMode
+ self.closeConfirmationRequired = closeConfirmationRequired
+ self.onCloseConfirmationDisabled = onCloseConfirmationDisabled
self.debugOverlayWidth = debugOverlayWidth
self.debugKeySequence = debugKeySequence
self.debugMouseSequence = debugMouseSequence
@@ -107,6 +169,23 @@
lastActivationWindowID = nil
lastActivationResult = nil
lastActivationError = nil
+ lastSpaceActivationSpaceID = nil
+ lastSpaceActivationDidRequestFocus = nil
+ lastSpaceActivationDisplayIdentifier = nil
+ lastSpaceActivationPreviousCurrentSpaceID = nil
+ lastSpaceActivationError = nil
+ lastCloseTargetKind = nil
+ lastCloseAppGroupIndex = nil
+ lastCloseAppName = nil
+ lastCloseWindowID = nil
+ lastCloseResult = nil
+ lastCloseError = nil
+ pendingCloseVerification = nil
+ suppressedCloseTargets = []
+ cancelPendingLockedSpaceUnlock()
+ lockedSpaceFilterID = nil
+ selectionBeforeSpaceFilter = nil
+ lastSpaceFilterAction = nil
lastDismissReason = nil
lastSystemCriticalAction = nil
lastSystemCriticalMonitorSource = nil
@@ -128,6 +207,26 @@
view.onCommitSelection = { [weak self] selection, source in
self?.commitSelection(selection, source: source)
}
+ view.onSpaceLaneClick = { [weak self] spaceID, clickCount in
+ self?.handleSpaceLaneClick(spaceID, clickCount: clickCount)
+ }
+ view.onBackgroundClick = { [weak self] in
+ self?.handleBackgroundClick() ?? false
+ }
+ view.onOpenSettings = { [weak self] in
+ self?.onOpenSettings?()
+ }
+ view.setSuppressEventInput(!debugMouseSequence.isEmpty || !debugKeySequence.isEmpty)
+ view.onRequestClose = { [weak self] request in
+ self?.requestClose(request)
+ }
+ view.onCloseConfirmationDisabled = { [weak self] in
+ guard let self else { return }
+ self.closeConfirmationRequired = false
+ self.onCloseConfirmationDisabled?()
+ }
+ view.setCloseConfirmationRequired(closeConfirmationRequired)
+ view.setWaterfallViewMode(waterfallViewMode)
view.apply(viewModel: nil)
view.beginPerformanceFirstFrameMeasurement()
coordinator.openQuickSwitch()
@@ -157,6 +256,9 @@
snapshotTask = nil
screenshotTask?.cancel()
screenshotTask = nil
+ cancelPendingLockedSpaceUnlock()
+ pendingCloseVerification = nil
+ view.clearCloseFeedback()
invalidateSystemCriticalMonitoring()
coordinator.closeQuickSwitch(reason: reason)
lifecycleResidualOverlayWindows = overlayWindowCount()
@@ -181,6 +283,23 @@
show()
}
+ func setWaterfallViewMode(_ mode: QuickSwitchWaterfallViewMode) {
+ guard waterfallViewMode != mode else { return }
+
+ waterfallViewMode = mode
+ DevelopmentDiagnostics.log("quickSwitch.session.waterfallViewModeChanged", [
+ "mode": mode.rawValue,
+ "visible": isVisible,
+ "hasViewModel": currentViewModel != nil
+ ])
+
+ view.setWaterfallViewMode(mode)
+ if isVisible, let currentViewModel {
+ view.applyProjected(viewModel: currentViewModel)
+ }
+ onSnapshotUpdated?()
+ }
+
func reportDictionary() -> [String: Any] {
let spaceCount = currentViewModel?.spaceCount ?? 0
let appCount = currentViewModel?.appShelf.count ?? 0
@@ -201,6 +320,9 @@
"initialSelectionWindowID": currentViewModel?.initialSelection?.windowID ?? NSNull(),
"appShelfNames": currentViewModel?.appShelf.map(\.app.name) ?? [],
"spaceLabels": currentViewModel?.displays.flatMap { $0.spaces.map(\.label) } ?? [],
+ "spaceFilterLockedSpaceID": lockedSpaceFilterID ?? NSNull(),
+ "spaceFilterActive": lockedSpaceFilterID != nil,
+ "lastSpaceFilterAction": lastSpaceFilterAction ?? NSNull(),
"overlayOpenElapsedMilliseconds": overlayOpenElapsedMilliseconds ?? NSNull(),
"snapshotStartElapsedMilliseconds": snapshotStartElapsedMilliseconds ?? NSNull(),
"snapshotDurationMilliseconds": snapshotDurationMilliseconds ?? NSNull(),
@@ -212,6 +334,18 @@
"lastActivationWindowID": lastActivationWindowID ?? NSNull(),
"lastActivationResult": lastActivationResult.map(Self.activationResultString) ?? NSNull(),
"lastActivationError": lastActivationError ?? NSNull(),
+ "lastSpaceActivationSpaceID": lastSpaceActivationSpaceID ?? NSNull(),
+ "lastSpaceActivationDidRequestFocus": lastSpaceActivationDidRequestFocus ?? NSNull(),
+ "lastSpaceActivationDisplayIdentifier": lastSpaceActivationDisplayIdentifier ?? NSNull(),
+ "lastSpaceActivationPreviousCurrentSpaceID": lastSpaceActivationPreviousCurrentSpaceID ?? NSNull(),
+ "lastSpaceActivationError": lastSpaceActivationError ?? NSNull(),
+ "lastCloseTargetKind": lastCloseTargetKind ?? NSNull(),
+ "lastCloseAppGroupIndex": lastCloseAppGroupIndex ?? NSNull(),
+ "lastCloseAppName": lastCloseAppName ?? NSNull(),
+ "lastCloseWindowID": lastCloseWindowID ?? NSNull(),
+ "lastCloseResult": lastCloseResult.map(Self.closeResultString) ?? NSNull(),
+ "lastCloseError": lastCloseError ?? NSNull(),
+ "suppressedCloseTargetCount": suppressedCloseTargets.count,
"lastDismissReason": lastDismissReason.map(Self.dismissReasonString) ?? NSNull(),
"overlayLevel": Int(coordinator.currentLevel),
"overlayMainMenuLevel": Int(CGWindowLevelForKey(.mainMenuWindow)),
@@ -264,16 +398,26 @@
updateLifecycleWindowHighWaterMark()
}
- private func scheduleSnapshotRefresh(for generation: Int) {
+ private func scheduleSnapshotRefresh(
+ for generation: Int,
+ replayDebugCommands: Bool = true,
+ delay: TimeInterval = 0
+ ) {
DevelopmentDiagnostics.log("quickSwitch.snapshot.schedule", [
"generation": generation,
- "visible": isVisible
+ "visible": isVisible,
+ "replayDebugCommands": replayDebugCommands,
+ "delayMilliseconds": Int(delay * 1_000)
])
snapshotTask?.cancel()
let loader = snapshotLoader
let showStartTime = showStartTime
snapshotTask = Task { [weak self] in
guard let self, !Task.isCancelled else { return }
+ if delay > 0 {
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ guard !Task.isCancelled else { return }
+ }
let loadTask = Task.detached(priority: .userInitiated) {
SnapshotLoadResult.load(using: loader, showStartTime: showStartTime)
@@ -285,12 +429,17 @@
}
guard !Task.isCancelled else { return }
- self.applySnapshotResult(result, generation: generation)
+ self.applySnapshotResult(
+ result,
+ generation: generation,
+ replayDebugCommands: replayDebugCommands
+ )
}
}
private func clearSnapshotState() {
currentSnapshot = nil
+ sourceViewModel = nil
currentViewModel = nil
lastSnapshotError = nil
}
@@ -362,7 +511,11 @@
onSnapshotUpdated?()
}
- private func commitSelection(_ selection: QuickSwitchSelection, source: QuickSwitchCommitSource) {
+ private func commitSelection(
+ _ selection: QuickSwitchSelection,
+ source: QuickSwitchCommitSource,
+ in viewModel: QuickSwitchViewModel? = nil
+ ) {
DevelopmentDiagnostics.log("quickSwitch.activation.commit.start", [
"source": source.rawValue,
"appGroupIndex": selection.appGroupIndex,
@@ -374,7 +527,7 @@
lastActivationWindowID = selection.windowID
lastActivationError = nil
- guard let window = window(for: selection) else {
+ guard let window = window(for: selection, in: viewModel ?? currentViewModel) else {
lastActivationResult = .windowNotFound
DevelopmentDiagnostics.log("quickSwitch.activation.commit.windowNotFound", [
"source": source.rawValue,
@@ -432,6 +585,455 @@
onSnapshotUpdated?()
}
+ private func handleSpaceLaneClick(_ spaceID: UInt64, clickCount: Int) {
+ if clickCount >= 2 {
+ if pendingLockedSpaceUnlockSpaceID == spaceID {
+ cancelPendingLockedSpaceUnlock()
+ activateLockedSpace(spaceID)
+ } else {
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.doubleClick.ignored", [
+ "spaceID": spaceID,
+ "lockedSpaceID": lockedSpaceFilterID ?? NSNull(),
+ "pendingUnlockSpaceID": pendingLockedSpaceUnlockSpaceID ?? NSNull()
+ ])
+ }
+ return
+ }
+
+ cancelPendingLockedSpaceUnlock()
+ if lockedSpaceFilterID == spaceID {
+ scheduleLockedSpaceUnlock(spaceID)
+ return
+ }
+
+ applySpaceLaneSingleClick(spaceID)
+ }
+
+ private func applySpaceLaneSingleClick(_ spaceID: UInt64) {
+ guard let sourceViewModel else {
+ lastSpaceFilterAction = "blockedNoSourceViewModel"
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.click.blocked", [
+ "spaceID": spaceID,
+ "reason": "noSourceViewModel"
+ ])
+ onSnapshotUpdated?()
+ return
+ }
+
+ if let fullscreenSelection = QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(
+ inSpaceID: spaceID,
+ viewModel: sourceViewModel
+ ) {
+ lockedSpaceFilterID = nil
+ selectionBeforeSpaceFilter = nil
+ lastSpaceFilterAction = "activateSingleFullscreen"
+ let visibleViewModel = projectedViewModel(from: sourceViewModel)
+ currentViewModel = visibleViewModel
+ view.applyProjected(viewModel: visibleViewModel)
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.singleFullscreenActivate", [
+ "spaceID": spaceID,
+ "windowID": fullscreenSelection.windowID
+ ])
+ commitSelection(fullscreenSelection, source: .mouse, in: sourceViewModel)
+ return
+ }
+
+ let preferredSelection: QuickSwitchSelection?
+ if lockedSpaceFilterID == spaceID {
+ lockedSpaceFilterID = nil
+ preferredSelection = selectionBeforeSpaceFilter
+ selectionBeforeSpaceFilter = nil
+ lastSpaceFilterAction = "unlock"
+ } else {
+ if lockedSpaceFilterID == nil {
+ selectionBeforeSpaceFilter = view.effectiveSelectionForProjection()
+ lastSpaceFilterAction = "lock"
+ } else {
+ lastSpaceFilterAction = "switch"
+ }
+ lockedSpaceFilterID = spaceID
+ preferredSelection = nil
+ }
+
+ let visibleViewModel = projectedViewModel(
+ from: sourceViewModel,
+ preferredSelection: preferredSelection
+ )
+ currentViewModel = visibleViewModel
+ view.applyProjected(viewModel: visibleViewModel)
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.apply", [
+ "spaceID": spaceID,
+ "lockedSpaceID": lockedSpaceFilterID,
+ "action": lastSpaceFilterAction,
+ "visibleAppCount": visibleViewModel.appShelf.count,
+ "visibleWindowCount": visibleViewModel.windowCount
+ ])
+
+ if disableScreenshotRefresh {
+ view.markScreenshotsNotRequested(for: visibleViewModel, reason: "disabledByLaunchOption")
+ screenshotTask?.cancel()
+ screenshotTask = nil
+ } else {
+ scheduleScreenshotRefresh(for: visibleViewModel, generation: sessionGeneration)
+ }
+
+ onSnapshotUpdated?()
+ }
+
+ @discardableResult
+ private func handleBackgroundClick() -> Bool {
+ guard lockedSpaceFilterID != nil else { return false }
+
+ cancelPendingLockedSpaceUnlock()
+ let unlocked = unlockSpaceFilter(action: "unlockBackground")
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.backgroundUnlock", [
+ "unlocked": unlocked
+ ])
+ return unlocked
+ }
+
+ private func scheduleLockedSpaceUnlock(_ spaceID: UInt64) {
+ pendingLockedSpaceUnlockSpaceID = spaceID
+ let delay = max(0.12, NSEvent.doubleClickInterval + 0.03)
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.unlock.pending", [
+ "spaceID": spaceID,
+ "delay": delay
+ ])
+
+ pendingLockedSpaceUnlockTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ guard !Task.isCancelled,
+ let self,
+ self.pendingLockedSpaceUnlockSpaceID == spaceID
+ else {
+ return
+ }
+
+ self.pendingLockedSpaceUnlockSpaceID = nil
+ self.pendingLockedSpaceUnlockTask = nil
+ self.applySpaceLaneSingleClick(spaceID)
+ }
+ }
+
+ private func cancelPendingLockedSpaceUnlock() {
+ pendingLockedSpaceUnlockTask?.cancel()
+ pendingLockedSpaceUnlockTask = nil
+ pendingLockedSpaceUnlockSpaceID = nil
+ }
+
+ @discardableResult
+ private func unlockSpaceFilter(action: String) -> Bool {
+ guard let sourceViewModel, lockedSpaceFilterID != nil else {
+ return false
+ }
+
+ lockedSpaceFilterID = nil
+ let preferredSelection = selectionBeforeSpaceFilter
+ selectionBeforeSpaceFilter = nil
+ lastSpaceFilterAction = action
+
+ let visibleViewModel = projectedViewModel(
+ from: sourceViewModel,
+ preferredSelection: preferredSelection
+ )
+ currentViewModel = visibleViewModel
+ view.applyProjected(viewModel: visibleViewModel)
+
+ if disableScreenshotRefresh {
+ view.markScreenshotsNotRequested(for: visibleViewModel, reason: "disabledByLaunchOption")
+ screenshotTask?.cancel()
+ screenshotTask = nil
+ } else {
+ scheduleScreenshotRefresh(for: visibleViewModel, generation: sessionGeneration)
+ }
+
+ onSnapshotUpdated?()
+ return true
+ }
+
+ private func activateLockedSpace(_ spaceID: UInt64) {
+ guard lockedSpaceFilterID == spaceID else {
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.activateSpace.ignored", [
+ "spaceID": spaceID,
+ "lockedSpaceID": lockedSpaceFilterID ?? NSNull()
+ ])
+ return
+ }
+
+ guard let sourceViewModel else {
+ lastSpaceFilterAction = "blockedNoSourceViewModel"
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.activateSpace.blocked", [
+ "spaceID": spaceID,
+ "reason": "noSourceViewModel"
+ ])
+ onSnapshotUpdated?()
+ return
+ }
+
+ lockedSpaceFilterID = nil
+ selectionBeforeSpaceFilter = nil
+ lastSpaceFilterAction = "activateSpace"
+ lastSpaceActivationSpaceID = spaceID
+ lastSpaceActivationDidRequestFocus = nil
+ lastSpaceActivationDisplayIdentifier = nil
+ lastSpaceActivationPreviousCurrentSpaceID = nil
+ lastSpaceActivationError = nil
+
+ let visibleViewModel = projectedViewModel(from: sourceViewModel)
+ currentViewModel = visibleViewModel
+ view.applyProjected(viewModel: visibleViewModel)
+
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.activateSpace.start", [
+ "spaceID": spaceID
+ ])
+ hide(reason: .userClosed)
+ let outcome = spaceActivationService.activate(spaceID: spaceID)
+ lastSpaceActivationSpaceID = outcome.targetSpaceID
+ lastSpaceActivationDidRequestFocus = outcome.didRequestFocus
+ lastSpaceActivationDisplayIdentifier = outcome.displayIdentifier
+ lastSpaceActivationPreviousCurrentSpaceID = outcome.previousCurrentSpaceID
+ lastSpaceActivationError = outcome.error
+ DevelopmentDiagnostics.log("quickSwitch.spaceFilter.activateSpace.result", [
+ "spaceID": outcome.targetSpaceID,
+ "displayIdentifier": outcome.displayIdentifier ?? NSNull(),
+ "previousCurrentSpaceID": outcome.previousCurrentSpaceID ?? NSNull(),
+ "didRequestFocus": outcome.didRequestFocus,
+ "error": outcome.error ?? NSNull()
+ ])
+ onSnapshotUpdated?()
+ }
+
+ private func requestClose(_ request: QuickSwitchCloseRequest) {
+ DevelopmentDiagnostics.log("quickSwitch.close.request.start", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "titleHash": DevelopmentDiagnostics.stableFingerprint(request.title),
+ "titleLength": request.title.count
+ ])
+ lastCloseTargetKind = request.kindDescription
+ lastCloseAppGroupIndex = request.appGroupIndex
+ lastCloseAppName = request.appName
+ lastCloseWindowID = request.windowID
+ lastCloseError = nil
+ pendingCloseVerification = nil
+ view.showCloseProgress(for: request)
+
+ do {
+ let result: WindowCloseResult
+ switch request {
+ case .app:
+ result = try windowCloseService.close(app: request.app)
+ case .window:
+ guard let window = request.window else {
+ lastCloseResult = .windowNotFound
+ suppressCloseTarget(request)
+ view.clearCloseFeedback()
+ onSnapshotUpdated?()
+ return
+ }
+ result = try windowCloseService.close(window: window)
+ }
+ lastCloseResult = result
+ DevelopmentDiagnostics.log("quickSwitch.close.request.result", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "result": Self.closeResultString(result)
+ ])
+ if result == .requested {
+ DevelopmentDiagnostics.log("quickSwitch.close.request.refreshAfterClose", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "optimisticRemove": false
+ ])
+ pendingCloseVerification = PendingCloseVerification(
+ request: request,
+ generation: sessionGeneration,
+ attempt: 0
+ )
+ scheduleSnapshotRefresh(
+ for: sessionGeneration,
+ replayDebugCommands: false,
+ delay: Self.closeVerificationRetryDelays[0]
+ )
+ } else if result == .windowNotFound {
+ suppressCloseTarget(request)
+ view.clearCloseFeedback()
+ DevelopmentDiagnostics.log("quickSwitch.close.request.staleTargetRemoved", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID
+ ])
+ scheduleSnapshotRefresh(
+ for: sessionGeneration,
+ replayDebugCommands: false,
+ delay: 0.10
+ )
+ } else {
+ view.showCloseFailure(message: closeFailureMessage(for: result), for: request)
+ }
+ } catch {
+ let errorSummary = DevelopmentDiagnostics.errorSummaryString(error)
+ lastCloseResult = .failed(errorSummary)
+ lastCloseError = errorSummary
+ pendingCloseVerification = nil
+ view.showCloseFailure(message: Self.closeFailedMessage, for: request)
+ var fields: [String: CustomStringConvertible?] = [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "titleHash": DevelopmentDiagnostics.stableFingerprint(request.title),
+ "titleLength": request.title.count
+ ]
+ DevelopmentDiagnostics.errorSummaryFields(error).forEach { fields[$0.key] = $0.value }
+ DevelopmentDiagnostics.log("quickSwitch.close.request.error", fields)
+ }
+
+ onSnapshotUpdated?()
+ }
+
+ private func suppressCloseTarget(_ request: QuickSwitchCloseRequest) {
+ let target = SuppressedCloseTarget(request)
+ if !suppressedCloseTargets.contains(target) {
+ suppressedCloseTargets.append(target)
+ }
+
+ guard let sourceViewModel else { return }
+ let visibleViewModel = projectedViewModel(from: sourceViewModel, preferredSelection: view.effectiveSelectionForProjection())
+ self.currentViewModel = visibleViewModel
+ view.applyProjected(viewModel: visibleViewModel)
+ DevelopmentDiagnostics.log("quickSwitch.close.optimisticRemove", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "remainingWindowCount": visibleViewModel.windowCount,
+ "suppressedCloseTargetCount": suppressedCloseTargets.count
+ ])
+ }
+
+ private func viewModel(
+ _ viewModel: QuickSwitchViewModel,
+ excluding suppressedTargets: [SuppressedCloseTarget]
+ ) -> QuickSwitchViewModel {
+ guard !suppressedTargets.isEmpty else { return viewModel }
+
+ var nextGlobalIndex = 0
+ let columns = viewModel.waterfallColumns.compactMap { column -> QuickSwitchWaterfallColumnViewModel? in
+ let windows = column.windows.filter { card in
+ !suppressedTargets.contains { target in
+ suppressedTarget(target, matches: card)
+ }
+ }
+ guard !windows.isEmpty else { return nil }
+
+ let reindexedWindows = windows.enumerated().map { windowIndex, card in
+ defer { nextGlobalIndex += 1 }
+ return QuickSwitchWindowCardViewModel(
+ appGroupIndex: column.appGroupIndex,
+ windowIndex: windowIndex,
+ globalIndex: nextGlobalIndex,
+ window: card.window,
+ primarySpaceID: card.primarySpaceID,
+ primarySpaceLabel: card.primarySpaceLabel
+ )
+ }
+
+ return QuickSwitchWaterfallColumnViewModel(
+ appGroupIndex: column.appGroupIndex,
+ app: column.app,
+ windows: reindexedWindows
+ )
+ }
+
+ let columnsByAppGroupIndex = Dictionary(uniqueKeysWithValues: columns.map { ($0.appGroupIndex, $0) })
+ let appShelf = viewModel.appShelf.compactMap { item -> QuickSwitchAppShelfItemViewModel? in
+ guard let column = columnsByAppGroupIndex[item.appGroupIndex] else { return nil }
+ return QuickSwitchAppShelfItemViewModel(
+ appGroupIndex: item.appGroupIndex,
+ app: item.app,
+ windowCount: column.windows.count,
+ primarySpaceIDs: orderedPrimarySpaceIDs(in: column.windows),
+ hasMinimizedWindows: column.windows.contains { $0.window.isMinimized },
+ hasFullscreenWindows: column.windows.contains { $0.window.isFullscreen }
+ )
+ }
+
+ return QuickSwitchViewModel(
+ displays: viewModel.displays,
+ appShelf: appShelf,
+ waterfallColumns: columns,
+ initialSelection: initialSelection(in: columns, fallback: viewModel.initialSelection),
+ lockedSpaceID: viewModel.lockedSpaceID
+ )
+ }
+
+ private func projectedViewModel(
+ from sourceViewModel: QuickSwitchViewModel,
+ preferredSelection: QuickSwitchSelection? = nil
+ ) -> QuickSwitchViewModel {
+ let closeProjectedViewModel = viewModel(sourceViewModel, excluding: suppressedCloseTargets)
+ return QuickSwitchSpaceFilterPolicy.projectedViewModel(
+ from: closeProjectedViewModel,
+ lockedSpaceID: lockedSpaceFilterID,
+ preferredSelection: preferredSelection
+ )
+ }
+
+ private func orderedPrimarySpaceIDs(in cards: [QuickSwitchWindowCardViewModel]) -> [UInt64] {
+ var seen = Set<UInt64>()
+ var result: [UInt64] = []
+ for card in cards {
+ guard let spaceID = card.primarySpaceID,
+ !seen.contains(spaceID)
+ else {
+ continue
+ }
+ seen.insert(spaceID)
+ result.append(spaceID)
+ }
+ return result
+ }
+
+ private func suppressedTarget(
+ _ target: SuppressedCloseTarget,
+ matches card: QuickSwitchWindowCardViewModel
+ ) -> Bool {
+ switch target {
+ case .window(let windowID):
+ return card.window.id == windowID
+ case .app(let app):
+ return appMatches(card.window.app, app)
+ }
+ }
+
+ private func initialSelection(
+ in columns: [QuickSwitchWaterfallColumnViewModel],
+ fallback: QuickSwitchSelection?
+ ) -> QuickSwitchSelection? {
+ if let fallback,
+ columns.contains(where: { column in
+ column.appGroupIndex == fallback.appGroupIndex
+ && column.windows.contains { $0.window.id == fallback.windowID }
+ }) {
+ return fallback
+ }
+
+ guard let firstCard = columns.first?.windows.first else { return nil }
+ return QuickSwitchSelection(
+ appGroupIndex: firstCard.appGroupIndex,
+ windowIndex: firstCard.windowIndex,
+ windowID: firstCard.window.id
+ )
+ }
+
private func shouldCloseAfterActivation(_ result: WindowActivationResult?) -> Bool {
switch result {
case .activated, .restored, .appActivatedOnly:
@@ -441,8 +1043,11 @@
}
}
- private func window(for selection: QuickSwitchSelection) -> AlignerWindow? {
- currentViewModel?
+ private func window(
+ for selection: QuickSwitchSelection,
+ in viewModel: QuickSwitchViewModel?
+ ) -> AlignerWindow? {
+ viewModel?
.waterfallColumns
.flatMap(\.windows)
.first { card in
@@ -453,7 +1058,11 @@
.window
}
- private func applySnapshotResult(_ result: SnapshotLoadResult, generation: Int) {
+ private func applySnapshotResult(
+ _ result: SnapshotLoadResult,
+ generation: Int,
+ replayDebugCommands: Bool
+ ) {
guard generation == sessionGeneration, isVisible else {
DevelopmentDiagnostics.log("quickSwitch.snapshot.drop", [
"resultGeneration": generation,
@@ -466,15 +1075,23 @@
switch result {
case .success(let success):
currentSnapshot = success.snapshot
- currentViewModel = success.viewModel
+ sourceViewModel = success.viewModel
+ let visibleViewModel = projectedViewModel(
+ from: success.viewModel,
+ preferredSelection: view.effectiveSelectionForProjection()
+ )
+ currentViewModel = visibleViewModel
lastSnapshotError = nil
snapshotStartElapsedMilliseconds = success.timing.startElapsedMilliseconds
snapshotDurationMilliseconds = success.timing.durationMilliseconds
snapshotRanOnMainThread = success.timing.ranOnMainThread
- view.apply(viewModel: success.viewModel)
+ view.apply(viewModel: visibleViewModel)
view.recordPerformanceFirstFrameIfNeeded()
- view.performDebugKeyboardCommands(debugKeySequence)
- view.performDebugMouseCommands(debugMouseSequence)
+ if replayDebugCommands {
+ view.performDebugKeyboardCommands(debugKeySequence)
+ view.performDebugMouseCommands(debugMouseSequence)
+ }
+ verifyPendingCloseIfNeeded(in: success.viewModel)
DevelopmentDiagnostics.log("quickSwitch.snapshot.success", [
"generation": generation,
"displayCount": success.viewModel.displays.count,
@@ -487,17 +1104,18 @@
])
guard isVisible else { return }
if disableScreenshotRefresh {
- view.markScreenshotsNotRequested(for: success.viewModel, reason: "disabledByLaunchOption")
+ view.markScreenshotsNotRequested(for: visibleViewModel, reason: "disabledByLaunchOption")
screenshotTask?.cancel()
screenshotTask = nil
DevelopmentDiagnostics.log("quickSwitch.screenshot.disabledByLaunchOption", [
"generation": generation
])
} else {
- scheduleScreenshotRefresh(for: success.viewModel, generation: generation)
+ scheduleScreenshotRefresh(for: visibleViewModel, generation: generation)
}
case .failure(let failure):
currentSnapshot = nil
+ sourceViewModel = nil
currentViewModel = nil
lastSnapshotError = failure.message
snapshotStartElapsedMilliseconds = failure.timing.startElapsedMilliseconds
@@ -506,6 +1124,7 @@
view.apply(viewModel: nil)
screenshotTask?.cancel()
screenshotTask = nil
+ failPendingCloseVerificationIfNeeded(reason: "snapshotFailure")
DevelopmentDiagnostics.log("quickSwitch.snapshot.failure", [
"generation": generation,
"message": failure.message,
@@ -515,6 +1134,104 @@
}
onSnapshotUpdated?()
+ }
+
+ private func verifyPendingCloseIfNeeded(in viewModel: QuickSwitchViewModel) {
+ guard let pendingCloseVerification else { return }
+
+ self.pendingCloseVerification = nil
+ let request = pendingCloseVerification.request
+ let stillPresent = closeTargetStillPresent(request, in: viewModel)
+ DevelopmentDiagnostics.log("quickSwitch.close.verify", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "requestedGeneration": pendingCloseVerification.generation,
+ "currentGeneration": sessionGeneration,
+ "attempt": pendingCloseVerification.attempt,
+ "stillPresent": stillPresent
+ ])
+
+ if stillPresent {
+ let nextAttempt = pendingCloseVerification.attempt + 1
+ if nextAttempt < Self.closeVerificationRetryDelays.count {
+ self.pendingCloseVerification = PendingCloseVerification(
+ request: request,
+ generation: sessionGeneration,
+ attempt: nextAttempt
+ )
+ scheduleSnapshotRefresh(
+ for: sessionGeneration,
+ replayDebugCommands: false,
+ delay: Self.closeVerificationRetryDelays[nextAttempt]
+ )
+ } else {
+ view.showCloseFailure(message: Self.closeRequestStillPresentMessage, for: request)
+ }
+ } else {
+ view.clearCloseFeedback()
+ }
+ }
+
+ private func failPendingCloseVerificationIfNeeded(reason: String) {
+ guard let pendingCloseVerification else { return }
+
+ self.pendingCloseVerification = nil
+ let request = pendingCloseVerification.request
+ DevelopmentDiagnostics.log("quickSwitch.close.verify.failed", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "requestedGeneration": pendingCloseVerification.generation,
+ "currentGeneration": sessionGeneration,
+ "reason": reason
+ ])
+ view.showCloseFailure(message: Self.closeFailedMessage, for: request)
+ }
+
+ private func closeFailureMessage(for result: WindowCloseResult) -> String {
+ switch result {
+ case .requested:
+ return Self.closeRequestStillPresentMessage
+ case .windowNotFound:
+ return Self.closeTargetNotFoundMessage
+ case .unsupported:
+ return Self.closeUnsupportedMessage
+ case .appNotFound, .failed:
+ return Self.closeFailedMessage
+ }
+ }
+
+ private func closeTargetStillPresent(
+ _ request: QuickSwitchCloseRequest,
+ in viewModel: QuickSwitchViewModel
+ ) -> Bool {
+ switch request {
+ case .window:
+ guard let windowID = request.windowID else { return false }
+ return viewModel.waterfallColumns
+ .flatMap(\.windows)
+ .contains { $0.window.id == windowID }
+ case .app:
+ return viewModel.appShelf.contains { appMatches($0.app, request.app) }
+ || viewModel.waterfallColumns.contains { appMatches($0.app, request.app) }
+ }
+ }
+
+ private func appMatches(_ candidate: AlignerApp, _ target: AlignerApp) -> Bool {
+ if let candidatePID = candidate.processIdentifier,
+ let targetPID = target.processIdentifier {
+ return candidatePID == targetPID
+ }
+
+ if !candidate.bundleIdentifier.isEmpty,
+ candidate.bundleIdentifier == target.bundleIdentifier {
+ return true
+ }
+
+ return candidate.name == target.name
}
private func scheduleScreenshotRefresh(
@@ -650,6 +1367,21 @@
}
}
+ private static func closeResultString(_ result: WindowCloseResult) -> String {
+ switch result {
+ case .requested:
+ return "requested"
+ case .windowNotFound:
+ return "windowNotFound"
+ case .appNotFound:
+ return "appNotFound"
+ case .unsupported:
+ return "unsupported"
+ case .failed(let message):
+ return "failed(\(message))"
+ }
+ }
+
private static func screenshotSourceString(_ source: ScreenshotResolutionSource) -> String {
switch source {
case .realScreenshot:
@@ -742,15 +1474,19 @@
let snapshotLoadDelay: TimeInterval?
let fixtureAppCount: Int?
let fixtureWindowsPerApp: Int?
+ let fixtureMultiDisplay: Bool
let fixtureCandidateFiltering: Bool
let fixtureActivation: Bool
+ let fixtureSpaceFilter: Bool
let debugHoveredAppGroupIndex: Int?
let debugOverlayWidth: CGFloat?
let debugKeySequence: [String]
let debugMouseSequence: [String]
let debugSystemCriticalAfter: TimeInterval?
let debugWindowActivation: Bool
+ let debugWindowClose: Bool
let disableScreenshotRefresh: Bool
+ let waterfallViewMode: QuickSwitchWaterfallViewMode?
let lifecycleCycles: Int?
let lifecycleInterval: TimeInterval
let lifecycleVisibleDuration: TimeInterval
@@ -766,15 +1502,19 @@
snapshotLoadDelay: timeInterval(for: "--round01-snapshot-load-delay", in: arguments),
fixtureAppCount: intValue(for: "--round01-fixture-app-count", in: arguments),
fixtureWindowsPerApp: intValue(for: "--round01-fixture-windows-per-app", in: arguments),
+ fixtureMultiDisplay: arguments.contains("--round01-fixture-multi-display"),
fixtureCandidateFiltering: arguments.contains("--round01-fixture-candidate-filtering"),
fixtureActivation: arguments.contains("--round01-fixture-window-activation"),
+ fixtureSpaceFilter: arguments.contains("--round01-fixture-space-filter"),
debugHoveredAppGroupIndex: intValue(for: "--round01-debug-hover-app-index", in: arguments),
debugOverlayWidth: cgFloatValue(for: "--round01-debug-overlay-width", in: arguments),
debugKeySequence: stringListValue(for: "--round01-debug-key-sequence", in: arguments),
debugMouseSequence: stringListValue(for: "--round01-debug-mouse-sequence", in: arguments),
debugSystemCriticalAfter: timeInterval(for: "--round01-debug-system-critical-after", in: arguments),
debugWindowActivation: arguments.contains("--round01-debug-window-activation"),
+ debugWindowClose: arguments.contains("--round01-debug-window-close"),
disableScreenshotRefresh: arguments.contains("--round01-disable-screenshot-refresh"),
+ waterfallViewMode: waterfallViewModeValue(for: "--round01-waterfall-view-mode", in: arguments),
lifecycleCycles: intValue(for: "--round01-quick-switch-lifecycle-cycles", in: arguments),
lifecycleInterval: timeInterval(for: "--round01-quick-switch-lifecycle-interval", in: arguments) ?? 0.02,
lifecycleVisibleDuration: timeInterval(for: "--round01-quick-switch-lifecycle-visible-duration", in: arguments) ?? 0.16
@@ -799,6 +1539,22 @@
return String(argument.dropFirst(prefix.count))
}
+ private static func waterfallViewModeValue(
+ for key: String,
+ in arguments: [String]
+ ) -> QuickSwitchWaterfallViewMode? {
+ guard let value = stringValue(for: key, in: arguments) else { return nil }
+
+ switch value {
+ case "vertical", "vertical-columns", "verticalColumns":
+ return .verticalColumns
+ case "horizontal", "horizontal-masonry", "horizontalMasonry":
+ return .horizontalMasonry
+ default:
+ return nil
+ }
+ }
+
private static func intValue(for key: String, in arguments: [String]) -> Int? {
stringValue(for: key, in: arguments).flatMap(Int.init)
}
--
Gitblit v1.9.3