From 81b5a5f847e74fe1106829b13db2cccda8dd5798 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 19 Jun 2026 19:48:59 +0800
Subject: [PATCH] Fix horizontal app index key handling
---
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 252 +++++++++++++++++++++++++++++++++++++++++++++++++-
1 files changed, 246 insertions(+), 6 deletions(-)
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index 5a36765..18d84f0 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -15,9 +15,11 @@
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?
@@ -35,6 +37,8 @@
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>?
@@ -52,6 +56,11 @@
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?
@@ -77,6 +86,7 @@
private var lifecycleResidualOverlayWindows = 0
private var lifecycleResidualVisibleOverlayWindows = 0
var onSnapshotUpdated: (() -> Void)?
+ var onToggleWaterfallViewMode: (() -> Void)?
private struct PendingCloseVerification {
let request: QuickSwitchCloseRequest
@@ -106,11 +116,13 @@
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,
@@ -122,9 +134,11 @@
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
@@ -155,6 +169,11 @@
lastActivationWindowID = nil
lastActivationResult = nil
lastActivationError = nil
+ lastSpaceActivationSpaceID = nil
+ lastSpaceActivationDidRequestFocus = nil
+ lastSpaceActivationDisplayIdentifier = nil
+ lastSpaceActivationPreviousCurrentSpaceID = nil
+ lastSpaceActivationError = nil
lastCloseTargetKind = nil
lastCloseAppGroupIndex = nil
lastCloseAppName = nil
@@ -163,6 +182,7 @@
lastCloseError = nil
pendingCloseVerification = nil
suppressedCloseTargets = []
+ cancelPendingLockedSpaceUnlock()
lockedSpaceFilterID = nil
selectionBeforeSpaceFilter = nil
lastSpaceFilterAction = nil
@@ -187,9 +207,16 @@
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.setSuppressEventInput(!debugMouseSequence.isEmpty || !debugKeySequence.isEmpty)
view.onRequestClose = { [weak self] request in
self?.requestClose(request)
}
@@ -199,6 +226,7 @@
self.onCloseConfirmationDisabled?()
}
view.setCloseConfirmationRequired(closeConfirmationRequired)
+ view.setWaterfallViewMode(waterfallViewMode)
view.apply(viewModel: nil)
view.beginPerformanceFirstFrameMeasurement()
coordinator.openQuickSwitch()
@@ -228,6 +256,7 @@
snapshotTask = nil
screenshotTask?.cancel()
screenshotTask = nil
+ cancelPendingLockedSpaceUnlock()
pendingCloseVerification = nil
view.clearCloseFeedback()
invalidateSystemCriticalMonitoring()
@@ -251,7 +280,44 @@
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 reportDictionary() -> [String: Any] {
@@ -288,6 +354,11 @@
"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(),
@@ -534,7 +605,31 @@
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", [
@@ -605,6 +700,129 @@
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,
@@ -646,12 +864,12 @@
"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,
@@ -1280,6 +1498,8 @@
let fixtureCandidateFiltering: Bool
let fixtureActivation: Bool
let fixtureSpaceFilter: Bool
+ let fixtureSplitView: Bool
+ let fixtureMultiPageIdentity: Bool
let debugHoveredAppGroupIndex: Int?
let debugOverlayWidth: CGFloat?
let debugKeySequence: [String]
@@ -1288,6 +1508,7 @@
let debugWindowActivation: Bool
let debugWindowClose: Bool
let disableScreenshotRefresh: Bool
+ let waterfallViewMode: QuickSwitchWaterfallViewMode?
let lifecycleCycles: Int?
let lifecycleInterval: TimeInterval
let lifecycleVisibleDuration: TimeInterval
@@ -1307,6 +1528,8 @@
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),
@@ -1315,6 +1538,7 @@
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
@@ -1339,6 +1563,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