| | |
| | | private let systemCriticalWindowDetector: any SystemCriticalWindowDetecting |
| | | private let disableScreenshotRefresh: Bool |
| | | private var waterfallViewMode: QuickSwitchWaterfallViewMode |
| | | private var theme: ThemePreference |
| | | private var closeConfirmationRequired: Bool |
| | | private let onCloseConfirmationDisabled: (() -> Void)? |
| | | private let debugOverlayWidth: CGFloat? |
| | |
| | | private var lastActivationWindowID: UInt32? |
| | | private var lastActivationResult: WindowActivationResult? |
| | | private var lastActivationError: String? |
| | | private var lastActivationDurationMilliseconds: Double? |
| | | private var lastSpaceActivationSpaceID: UInt64? |
| | | private var lastSpaceActivationDidRequestFocus: Bool? |
| | | private var lastSpaceActivationDisplayIdentifier: String? |
| | |
| | | private var lastCloseError: String? |
| | | private var pendingCloseVerification: PendingCloseVerification? |
| | | private var suppressedCloseTargets: [SuppressedCloseTarget] = [] |
| | | private var suppressedActivationWindowIDs = Set<UInt32>() |
| | | private var lastDismissReason: DismissReason? |
| | | private var lastSystemCriticalAction: String? |
| | | private var lastSystemCriticalMonitorSource: String? |
| | |
| | | private var lifecycleResidualOverlayWindows = 0 |
| | | private var lifecycleResidualVisibleOverlayWindows = 0 |
| | | var onSnapshotUpdated: (() -> Void)? |
| | | var onOpenSettings: (() -> Void)? |
| | | var onToggleWaterfallViewMode: (() -> Void)? |
| | | var onToggleTheme: (() -> Void)? |
| | | |
| | | private struct PendingCloseVerification { |
| | | let request: QuickSwitchCloseRequest |
| | |
| | | systemCriticalWindowDetector: any SystemCriticalWindowDetecting = CGWindowSystemCriticalWindowDetector(), |
| | | disableScreenshotRefresh: Bool = false, |
| | | waterfallViewMode: QuickSwitchWaterfallViewMode = .verticalColumns, |
| | | theme: ThemePreference = .light, |
| | | closeConfirmationRequired: Bool = true, |
| | | onCloseConfirmationDisabled: (() -> Void)? = nil, |
| | | debugHoveredAppGroupIndex: Int? = nil, |
| | |
| | | self.systemCriticalWindowDetector = systemCriticalWindowDetector |
| | | self.disableScreenshotRefresh = disableScreenshotRefresh |
| | | self.waterfallViewMode = waterfallViewMode |
| | | self.theme = theme |
| | | self.closeConfirmationRequired = closeConfirmationRequired |
| | | self.onCloseConfirmationDisabled = onCloseConfirmationDisabled |
| | | self.debugOverlayWidth = debugOverlayWidth |
| | |
| | | lastActivationWindowID = nil |
| | | lastActivationResult = nil |
| | | lastActivationError = nil |
| | | lastActivationDurationMilliseconds = nil |
| | | lastSpaceActivationSpaceID = nil |
| | | lastSpaceActivationDidRequestFocus = nil |
| | | lastSpaceActivationDisplayIdentifier = nil |
| | |
| | | lastCloseError = nil |
| | | pendingCloseVerification = nil |
| | | suppressedCloseTargets = [] |
| | | suppressedActivationWindowIDs = [] |
| | | cancelPendingLockedSpaceUnlock() |
| | | lockedSpaceFilterID = nil |
| | | selectionBeforeSpaceFilter = nil |
| | |
| | | view.onBackgroundClick = { [weak self] in |
| | | self?.handleBackgroundClick() ?? false |
| | | } |
| | | view.onOpenSettings = { [weak self] in |
| | | self?.onOpenSettings?() |
| | | view.onToggleWaterfallViewMode = { [weak self] in |
| | | self?.onToggleWaterfallViewMode?() |
| | | } |
| | | view.onToggleTheme = { [weak self] in |
| | | self?.onToggleTheme?() |
| | | } |
| | | view.setSuppressEventInput(!debugMouseSequence.isEmpty || !debugKeySequence.isEmpty) |
| | | view.onRequestClose = { [weak self] request in |
| | |
| | | } |
| | | view.setCloseConfirmationRequired(closeConfirmationRequired) |
| | | view.setWaterfallViewMode(waterfallViewMode) |
| | | view.setTheme(theme) |
| | | view.apply(viewModel: nil) |
| | | view.beginPerformanceFirstFrameMeasurement() |
| | | coordinator.openQuickSwitch() |
| | |
| | | |
| | | func retriggerFromShortcut() { |
| | | DevelopmentDiagnostics.log("quickSwitch.session.retriggerFromShortcut") |
| | | show() |
| | | guard isVisible else { |
| | | show() |
| | | return |
| | | } |
| | | |
| | | guard currentViewModel != nil else { |
| | | DevelopmentDiagnostics.log("quickSwitch.session.retriggerFromShortcut.noSnapshot", [ |
| | | "action": "fallbackShow" |
| | | ]) |
| | | show() |
| | | return |
| | | } |
| | | |
| | | let didCycle = view.hoverNextAppGroupIndex() |
| | | DevelopmentDiagnostics.log("quickSwitch.session.retriggerFromShortcut.hoverApp", [ |
| | | "didCycle": didCycle |
| | | ]) |
| | | |
| | | if !didCycle { |
| | | show() |
| | | } |
| | | } |
| | | |
| | | func setWaterfallViewMode(_ mode: QuickSwitchWaterfallViewMode) { |
| | |
| | | if isVisible, let currentViewModel { |
| | | view.applyProjected(viewModel: currentViewModel) |
| | | } |
| | | onSnapshotUpdated?() |
| | | } |
| | | |
| | | func setTheme(_ theme: ThemePreference) { |
| | | guard self.theme != theme else { return } |
| | | |
| | | self.theme = theme |
| | | DevelopmentDiagnostics.log("quickSwitch.session.themeChanged", [ |
| | | "theme": theme.rawValue, |
| | | "visible": isVisible, |
| | | "hasViewModel": currentViewModel != nil |
| | | ]) |
| | | |
| | | view.setTheme(theme) |
| | | onSnapshotUpdated?() |
| | | } |
| | | |
| | |
| | | "lastActivationWindowID": lastActivationWindowID ?? NSNull(), |
| | | "lastActivationResult": lastActivationResult.map(Self.activationResultString) ?? NSNull(), |
| | | "lastActivationError": lastActivationError ?? NSNull(), |
| | | "lastActivationDurationMilliseconds": lastActivationDurationMilliseconds ?? NSNull(), |
| | | "lastSpaceActivationSpaceID": lastSpaceActivationSpaceID ?? NSNull(), |
| | | "lastSpaceActivationDidRequestFocus": lastSpaceActivationDidRequestFocus ?? NSNull(), |
| | | "lastSpaceActivationDisplayIdentifier": lastSpaceActivationDisplayIdentifier ?? NSNull(), |
| | |
| | | "lastCloseResult": lastCloseResult.map(Self.closeResultString) ?? NSNull(), |
| | | "lastCloseError": lastCloseError ?? NSNull(), |
| | | "suppressedCloseTargetCount": suppressedCloseTargets.count, |
| | | "suppressedActivationWindowCount": suppressedActivationWindowIDs.count, |
| | | "suppressedActivationWindowIDs": Array(suppressedActivationWindowIDs).sorted(), |
| | | "lastDismissReason": lastDismissReason.map(Self.dismissReasonString) ?? NSNull(), |
| | | "overlayLevel": Int(coordinator.currentLevel), |
| | | "overlayMainMenuLevel": Int(CGWindowLevelForKey(.mainMenuWindow)), |
| | |
| | | && lifecycleResidualVisibleOverlayWindows == 0 |
| | | ), |
| | | "rootView": view.reportDictionary() |
| | | ] |
| | | } |
| | | |
| | | func performanceDiagnosticSummary() -> [String: Any] { |
| | | let applications = currentSnapshot?.appGroups.map { group in |
| | | [ |
| | | "name": group.app.name, |
| | | "bundleIdentifier": group.app.bundleIdentifier, |
| | | "processIdentifier": group.app.processIdentifier ?? NSNull(), |
| | | "windowCount": group.windows.count, |
| | | "minimizedWindowCount": group.windows.filter { $0.window.isMinimized }.count, |
| | | "fullscreenWindowCount": group.windows.filter { $0.window.isFullscreen }.count |
| | | ] as [String: Any] |
| | | } ?? [] |
| | | |
| | | return [ |
| | | "snapshotLoaded": currentSnapshot != nil, |
| | | "snapshotError": lastSnapshotError ?? NSNull(), |
| | | "quickSwitchVisible": isVisible, |
| | | "displayCount": currentSnapshot?.displays.count ?? 0, |
| | | "spaceCount": currentSnapshot?.displays.flatMap(\.spaces).count ?? 0, |
| | | "applicationCount": applications.count, |
| | | "windowCount": currentSnapshot?.appGroups.flatMap(\.windows).count ?? 0, |
| | | "applications": applications, |
| | | "timingsMilliseconds": [ |
| | | "overlayOpen": overlayOpenElapsedMilliseconds ?? NSNull(), |
| | | "snapshotStart": snapshotStartElapsedMilliseconds ?? NSNull(), |
| | | "snapshot": snapshotDurationMilliseconds ?? NSNull(), |
| | | "lastActivation": lastActivationDurationMilliseconds ?? NSNull() |
| | | ] as [String: Any], |
| | | "snapshotRanOnMainThread": snapshotRanOnMainThread ?? NSNull(), |
| | | "lastActivationResult": lastActivationResult.map(Self.activationResultString) ?? NSNull(), |
| | | "lastActivationError": lastActivationError ?? NSNull(), |
| | | "lastDismissReason": lastDismissReason.map(Self.dismissReasonString) ?? NSNull() |
| | | ] |
| | | } |
| | | |
| | |
| | | lastCommitSource = source |
| | | lastActivationWindowID = selection.windowID |
| | | lastActivationError = nil |
| | | lastActivationDurationMilliseconds = nil |
| | | |
| | | guard let window = window(for: selection, in: viewModel ?? currentViewModel) else { |
| | | lastActivationResult = .windowNotFound |
| | | suppressActivationWindowID(selection.windowID, reason: "selectionMissing") |
| | | DevelopmentDiagnostics.log("quickSwitch.activation.commit.windowNotFound", [ |
| | | "source": source.rawValue, |
| | | "appGroupIndex": selection.appGroupIndex, |
| | |
| | | ]) |
| | | hide(reason: .userClosed) |
| | | |
| | | let activationStartedAt = CACurrentMediaTime() |
| | | do { |
| | | lastActivationResult = try windowActivationService.activate(window: window) |
| | | lastActivationDurationMilliseconds = (CACurrentMediaTime() - activationStartedAt) * 1_000 |
| | | DevelopmentDiagnostics.log("quickSwitch.activation.commit.result", [ |
| | | "source": source.rawValue, |
| | | "windowID": window.id, |
| | |
| | | "titleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "titleLength": window.title.count, |
| | | "titleIsEmpty": window.title.isEmpty, |
| | | "result": lastActivationResult.map(Self.activationResultString) |
| | | "result": lastActivationResult.map(Self.activationResultString), |
| | | "durationMilliseconds": lastActivationDurationMilliseconds |
| | | ]) |
| | | } catch { |
| | | lastActivationDurationMilliseconds = (CACurrentMediaTime() - activationStartedAt) * 1_000 |
| | | let errorSummary = DevelopmentDiagnostics.errorSummaryString(error) |
| | | lastActivationResult = .failed(errorSummary) |
| | | lastActivationError = errorSummary |
| | |
| | | "appName": window.app.name, |
| | | "titleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "titleLength": window.title.count, |
| | | "titleIsEmpty": window.title.isEmpty |
| | | "titleIsEmpty": window.title.isEmpty, |
| | | "durationMilliseconds": lastActivationDurationMilliseconds |
| | | ] |
| | | DevelopmentDiagnostics.errorSummaryFields(error).forEach { fields[$0.key] = $0.value } |
| | | DevelopmentDiagnostics.log("quickSwitch.activation.commit.error", fields) |
| | | } |
| | | |
| | | if shouldSuppressAfterActivationResult(lastActivationResult) { |
| | | suppressActivationWindowID(window.id, reason: lastActivationResult.map(Self.activationResultString) ?? "unknown") |
| | | } |
| | | |
| | | if shouldCloseAfterActivation(lastActivationResult) { |
| | |
| | | |
| | | private func handleSpaceLaneClick(_ spaceID: UInt64, clickCount: Int) { |
| | | if clickCount >= 2 { |
| | | if pendingLockedSpaceUnlockSpaceID == spaceID { |
| | | if pendingLockedSpaceUnlockSpaceID == spaceID || lockedSpaceFilterID == spaceID { |
| | | cancelPendingLockedSpaceUnlock() |
| | | activateLockedSpace(spaceID) |
| | | } else { |
| | |
| | | ]) |
| | | } |
| | | |
| | | private func suppressActivationWindowID(_ windowID: UInt32, reason: String) { |
| | | guard windowID != 0 else { return } |
| | | suppressedActivationWindowIDs.insert(windowID) |
| | | |
| | | guard let sourceViewModel else { return } |
| | | let visibleViewModel = projectedViewModel(from: sourceViewModel, preferredSelection: view.effectiveSelectionForProjection()) |
| | | self.currentViewModel = visibleViewModel |
| | | view.applyProjected(viewModel: visibleViewModel) |
| | | DevelopmentDiagnostics.log("quickSwitch.activation.suppressWindow", [ |
| | | "windowID": windowID, |
| | | "reason": reason, |
| | | "remainingWindowCount": visibleViewModel.windowCount, |
| | | "suppressedActivationWindowCount": suppressedActivationWindowIDs.count |
| | | ]) |
| | | } |
| | | |
| | | private func viewModel( |
| | | _ viewModel: QuickSwitchViewModel, |
| | | excluding suppressedTargets: [SuppressedCloseTarget] |
| | | excluding suppressedTargets: [SuppressedCloseTarget], |
| | | suppressedActivationWindowIDs: Set<UInt32> = [] |
| | | ) -> QuickSwitchViewModel { |
| | | guard !suppressedTargets.isEmpty else { return viewModel } |
| | | guard !suppressedTargets.isEmpty || !suppressedActivationWindowIDs.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 |
| | | let isActivationSuppressed = suppressedActivationWindowIDs.contains(card.window.id) |
| | | let isCloseSuppressed = suppressedTargets.contains { target in |
| | | suppressedTarget(target, matches: card) |
| | | } |
| | | return !isActivationSuppressed && !isCloseSuppressed |
| | | } |
| | | guard !windows.isEmpty else { return nil } |
| | | |
| | |
| | | from sourceViewModel: QuickSwitchViewModel, |
| | | preferredSelection: QuickSwitchSelection? = nil |
| | | ) -> QuickSwitchViewModel { |
| | | let closeProjectedViewModel = viewModel(sourceViewModel, excluding: suppressedCloseTargets) |
| | | let closeProjectedViewModel = viewModel( |
| | | sourceViewModel, |
| | | excluding: suppressedCloseTargets, |
| | | suppressedActivationWindowIDs: suppressedActivationWindowIDs |
| | | ) |
| | | return QuickSwitchSpaceFilterPolicy.projectedViewModel( |
| | | from: closeProjectedViewModel, |
| | | lockedSpaceID: lockedSpaceFilterID, |
| | |
| | | |
| | | private func shouldCloseAfterActivation(_ result: WindowActivationResult?) -> Bool { |
| | | switch result { |
| | | case .activated, .restored, .appActivatedOnly: |
| | | case .activated, .restored: |
| | | return true |
| | | case .windowNotFound, .unsupported, .activationFailed, .failed, nil: |
| | | case .appActivatedOnly, .windowNotFound, .unsupported, .activationFailed, .failed, nil: |
| | | return false |
| | | } |
| | | } |
| | | |
| | | private func shouldSuppressAfterActivationResult(_ result: WindowActivationResult?) -> Bool { |
| | | switch result { |
| | | case .windowNotFound, .unsupported, .activationFailed, .failed: |
| | | return true |
| | | case .activated, .restored, .appActivatedOnly, nil: |
| | | return false |
| | | } |
| | | } |
| | |
| | | let fixtureCandidateFiltering: Bool |
| | | let fixtureActivation: Bool |
| | | let fixtureSpaceFilter: Bool |
| | | let fixtureSplitView: Bool |
| | | let fixtureMultiPageIdentity: Bool |
| | | let debugHoveredAppGroupIndex: Int? |
| | | let debugOverlayWidth: CGFloat? |
| | | let debugKeySequence: [String] |
| | |
| | | let lifecycleCycles: Int? |
| | | let lifecycleInterval: TimeInterval |
| | | let lifecycleVisibleDuration: TimeInterval |
| | | let simulateDockReopenAfter: TimeInterval? |
| | | |
| | | static func parse(arguments: [String]) -> Round1QuickSwitchLaunchOptions { |
| | | Round1QuickSwitchLaunchOptions( |
| | |
| | | fixtureCandidateFiltering: arguments.contains("--round01-fixture-candidate-filtering"), |
| | | fixtureActivation: arguments.contains("--round01-fixture-window-activation"), |
| | | fixtureSpaceFilter: arguments.contains("--round01-fixture-space-filter"), |
| | | fixtureSplitView: arguments.contains("--round01-fixture-split-view"), |
| | | fixtureMultiPageIdentity: arguments.contains("--round01-fixture-multi-page-identity"), |
| | | 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), |
| | |
| | | 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 |
| | | lifecycleVisibleDuration: timeInterval(for: "--round01-quick-switch-lifecycle-visible-duration", in: arguments) ?? 0.16, |
| | | simulateDockReopenAfter: timeInterval(for: "--round01-simulate-dock-reopen-after", in: arguments) |
| | | ) |
| | | } |
| | | |