| | |
| | | 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 theme: ThemePreference |
| | | private var closeConfirmationRequired: Bool |
| | | private let onCloseConfirmationDisabled: (() -> Void)? |
| | | private let debugOverlayWidth: CGFloat? |
| | |
| | | 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>? |
| | |
| | | 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 lifecycleResidualOverlayWindows = 0 |
| | | private var lifecycleResidualVisibleOverlayWindows = 0 |
| | | var onSnapshotUpdated: (() -> Void)? |
| | | var onToggleWaterfallViewMode: (() -> Void)? |
| | | var onToggleTheme: (() -> Void)? |
| | | |
| | | private struct PendingCloseVerification { |
| | | let request: QuickSwitchCloseRequest |
| | |
| | | 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, |
| | | theme: ThemePreference = .light, |
| | | closeConfirmationRequired: Bool = true, |
| | | onCloseConfirmationDisabled: (() -> Void)? = nil, |
| | | debugHoveredAppGroupIndex: Int? = nil, |
| | |
| | | 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.theme = theme |
| | | self.closeConfirmationRequired = closeConfirmationRequired |
| | | self.onCloseConfirmationDisabled = onCloseConfirmationDisabled |
| | | self.debugOverlayWidth = debugOverlayWidth |
| | |
| | | lastActivationWindowID = nil |
| | | lastActivationResult = nil |
| | | lastActivationError = nil |
| | | lastSpaceActivationSpaceID = nil |
| | | lastSpaceActivationDidRequestFocus = nil |
| | | lastSpaceActivationDisplayIdentifier = nil |
| | | lastSpaceActivationPreviousCurrentSpaceID = nil |
| | | lastSpaceActivationError = nil |
| | | lastCloseTargetKind = nil |
| | | lastCloseAppGroupIndex = nil |
| | | lastCloseAppName = nil |
| | |
| | | lastCloseError = nil |
| | | pendingCloseVerification = nil |
| | | suppressedCloseTargets = [] |
| | | cancelPendingLockedSpaceUnlock() |
| | | lockedSpaceFilterID = nil |
| | | selectionBeforeSpaceFilter = nil |
| | | lastSpaceFilterAction = nil |
| | |
| | | view.onCommitSelection = { [weak self] selection, source in |
| | | self?.commitSelection(selection, source: source) |
| | | } |
| | | view.onSpaceLaneClick = { [weak self] spaceID in |
| | | self?.handleSpaceLaneClick(spaceID) |
| | | view.onSpaceLaneClick = { [weak self] spaceID, clickCount in |
| | | self?.handleSpaceLaneClick(spaceID, clickCount: clickCount) |
| | | } |
| | | view.onBackgroundClick = { [weak self] in |
| | | self?.handleBackgroundClick() ?? false |
| | | } |
| | | 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 |
| | | self?.requestClose(request) |
| | | } |
| | |
| | | self.onCloseConfirmationDisabled?() |
| | | } |
| | | view.setCloseConfirmationRequired(closeConfirmationRequired) |
| | | view.setWaterfallViewMode(waterfallViewMode) |
| | | view.setTheme(theme) |
| | | view.apply(viewModel: nil) |
| | | view.beginPerformanceFirstFrameMeasurement() |
| | | coordinator.openQuickSwitch() |
| | |
| | | snapshotTask = nil |
| | | screenshotTask?.cancel() |
| | | screenshotTask = nil |
| | | cancelPendingLockedSpaceUnlock() |
| | | pendingCloseVerification = nil |
| | | view.clearCloseFeedback() |
| | | invalidateSystemCriticalMonitoring() |
| | |
| | | |
| | | 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) { |
| | | 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 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?() |
| | | } |
| | | |
| | | func reportDictionary() -> [String: Any] { |
| | |
| | | "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(), |
| | |
| | | onSnapshotUpdated?() |
| | | } |
| | | |
| | | private func handleSpaceLaneClick(_ spaceID: UInt64) { |
| | | 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", [ |
| | |
| | | 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, |
| | |
| | | "result": Self.closeResultString(result) |
| | | ]) |
| | | if result == .requested { |
| | | suppressCloseTarget(request) |
| | | DevelopmentDiagnostics.log("quickSwitch.close.request.refreshAfterClose", [ |
| | | "targetKind": request.kindDescription, |
| | | "appGroupIndex": request.appGroupIndex, |
| | | "appName": request.appName, |
| | | "windowID": request.windowID |
| | | "windowID": request.windowID, |
| | | "optimisticRemove": false |
| | | ]) |
| | | pendingCloseVerification = PendingCloseVerification( |
| | | request: request, |
| | |
| | | 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 debugWindowActivation: Bool |
| | | let debugWindowClose: Bool |
| | | let disableScreenshotRefresh: Bool |
| | | let waterfallViewMode: QuickSwitchWaterfallViewMode? |
| | | let lifecycleCycles: Int? |
| | | let lifecycleInterval: TimeInterval |
| | | let lifecycleVisibleDuration: TimeInterval |
| | |
| | | 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), |
| | |
| | | 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 |
| | |
| | | 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) |
| | | } |