Ariver
2026-08-14 9c5edf5be0b7416a468f6686543cc3d30a6a5821
C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
@@ -1,3 +1,4 @@
import CoreGraphics
import Foundation
public struct QuickSwitchViewModel: Equatable, Sendable {
@@ -5,17 +6,20 @@
    public let appShelf: [QuickSwitchAppShelfItemViewModel]
    public let waterfallColumns: [QuickSwitchWaterfallColumnViewModel]
    public let initialSelection: QuickSwitchSelection?
    public let lockedSpaceID: UInt64?
    public init(
        displays: [QuickSwitchDisplayViewModel],
        appShelf: [QuickSwitchAppShelfItemViewModel],
        waterfallColumns: [QuickSwitchWaterfallColumnViewModel],
        initialSelection: QuickSwitchSelection?
        initialSelection: QuickSwitchSelection?,
        lockedSpaceID: UInt64? = nil
    ) {
        self.displays = displays
        self.appShelf = appShelf
        self.waterfallColumns = waterfallColumns
        self.initialSelection = initialSelection
        self.lockedSpaceID = lockedSpaceID
    }
    public var spaceCount: Int {
@@ -59,6 +63,7 @@
    public let windowCount: Int
    public let appCount: Int
    public let appNames: [String]
    public let splitViewAppNames: [String]
    public init(
        id: UInt64,
@@ -69,7 +74,8 @@
        isCurrent: Bool = false,
        windowCount: Int,
        appCount: Int,
        appNames: [String] = []
        appNames: [String] = [],
        splitViewAppNames: [String] = []
    ) {
        self.id = id
        self.displayUUID = displayUUID
@@ -80,6 +86,7 @@
        self.windowCount = windowCount
        self.appCount = appCount
        self.appNames = appNames
        self.splitViewAppNames = splitViewAppNames
    }
}
@@ -163,12 +170,14 @@
        let windowCountsBySpace = countWindowsBySpace(windowItems)
        let appCountsBySpace = countAppsBySpace(snapshot.appGroups)
        let appNamesBySpace = appNamesBySpace(snapshot.appGroups)
        let splitViewAppNamesBySpace = splitViewAppNamesBySpace(snapshot.appGroups)
        let displays = displayViewModels(
            from: snapshot.displays,
            currentSpaceIDs: currentSpaceIDs,
            windowCountsBySpace: windowCountsBySpace,
            appCountsBySpace: appCountsBySpace,
            appNamesBySpace: appNamesBySpace
            appNamesBySpace: appNamesBySpace,
            splitViewAppNamesBySpace: splitViewAppNamesBySpace
        )
        let appShelf = appShelfViewModels(from: snapshot.appGroups)
@@ -197,7 +206,8 @@
            displays: displays,
            appShelf: appShelf,
            waterfallColumns: columns,
            initialSelection: QuickSwitchFocusPolicy.initialSelection(in: snapshot)
            initialSelection: QuickSwitchFocusPolicy.initialSelection(in: snapshot),
            lockedSpaceID: nil
        )
    }
@@ -206,7 +216,8 @@
        currentSpaceIDs: Set<UInt64>,
        windowCountsBySpace: [UInt64: Int],
        appCountsBySpace: [UInt64: Int],
        appNamesBySpace: [UInt64: [String]]
        appNamesBySpace: [UInt64: [String]],
        splitViewAppNamesBySpace: [UInt64: [String]]
    ) -> [QuickSwitchDisplayViewModel] {
        let sortedDisplays = displays
            .sorted { $0.uuid.localizedStandardCompare($1.uuid) == .orderedAscending }
@@ -228,7 +239,8 @@
                            isCurrent: currentSpaceIDs.contains(space.id),
                            windowCount: windowCountsBySpace[space.id] ?? 0,
                            appCount: appCountsBySpace[space.id] ?? 0,
                            appNames: appNamesBySpace[space.id] ?? []
                            appNames: appNamesBySpace[space.id] ?? [],
                            splitViewAppNames: splitViewAppNamesBySpace[space.id] ?? []
                        )
                    }
@@ -305,6 +317,54 @@
        return namesBySpace
    }
    private static func splitViewAppNamesBySpace(_ appGroups: [QuickSwitchAppGroup]) -> [UInt64: [String]] {
        struct Candidate {
            let appName: String
            let windowID: UInt32
            let frame: CGRect
        }
        var candidatesBySpace: [UInt64: [Candidate]] = [:]
        for group in appGroups {
            for item in group.windows {
                guard item.window.isFullscreen,
                      let primarySpaceID = item.primarySpaceID,
                      let frame = item.window.frame,
                      frame.width > 0,
                      frame.height > 0
                else {
                    continue
                }
                candidatesBySpace[primarySpaceID, default: []].append(
                    Candidate(
                        appName: group.app.name,
                        windowID: item.window.id,
                        frame: frame
                    )
                )
            }
        }
        return candidatesBySpace.compactMapValues { candidates in
            guard candidates.count >= 2 else { return nil }
            return candidates
                .sorted { lhs, rhs in
                    if lhs.frame.midX != rhs.frame.midX {
                        return lhs.frame.midX < rhs.frame.midX
                    }
                    if lhs.frame.midY != rhs.frame.midY {
                        return lhs.frame.midY < rhs.frame.midY
                    }
                    return lhs.windowID < rhs.windowID
                }
                .prefix(2)
                .map(\.appName)
        }
    }
    private static func orderedUnique<T: Hashable>(_ values: [T]) -> [T] {
        var seen = Set<T>()
        var result: [T] = []
@@ -339,3 +399,148 @@
        return "\(displayLabel(for: displayIndex))\(space.index)"
    }
}
public enum QuickSwitchSpaceFilterPolicy {
    public static func projectedViewModel(
        from viewModel: QuickSwitchViewModel,
        lockedSpaceID: UInt64?,
        preferredSelection: QuickSwitchSelection? = nil
    ) -> QuickSwitchViewModel {
        guard let lockedSpaceID else {
            return replacingInitialSelection(
                in: viewModel,
                lockedSpaceID: nil,
                preferredSelection: preferredSelection
            )
        }
        var nextGlobalIndex = 0
        let columns = viewModel.waterfallColumns.compactMap { column -> QuickSwitchWaterfallColumnViewModel? in
            let windows = column.windows.filter { $0.primarySpaceID == lockedSpaceID }
            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, preferredSelection: preferredSelection),
            lockedSpaceID: lockedSpaceID
        )
    }
    public static func singleFullscreenSelection(
        inSpaceID spaceID: UInt64,
        viewModel: QuickSwitchViewModel
    ) -> QuickSwitchSelection? {
        guard viewModel.displays
            .flatMap(\.spaces)
            .contains(where: { $0.id == spaceID && $0.type == .fullscreen })
        else {
            return nil
        }
        let cards = viewModel.waterfallColumns
            .flatMap(\.windows)
            .filter { $0.primarySpaceID == spaceID }
        guard cards.count == 1,
              let card = cards.first,
              card.window.isFullscreen
        else {
            return nil
        }
        return QuickSwitchSelection(
            appGroupIndex: card.appGroupIndex,
            windowIndex: card.windowIndex,
            windowID: card.window.id
        )
    }
    private static func replacingInitialSelection(
        in viewModel: QuickSwitchViewModel,
        lockedSpaceID: UInt64?,
        preferredSelection: QuickSwitchSelection?
    ) -> QuickSwitchViewModel {
        QuickSwitchViewModel(
            displays: viewModel.displays,
            appShelf: viewModel.appShelf,
            waterfallColumns: viewModel.waterfallColumns,
            initialSelection: initialSelection(in: viewModel.waterfallColumns, preferredSelection: preferredSelection)
                ?? viewModel.initialSelection,
            lockedSpaceID: lockedSpaceID
        )
    }
    private static func initialSelection(
        in columns: [QuickSwitchWaterfallColumnViewModel],
        preferredSelection: QuickSwitchSelection?
    ) -> QuickSwitchSelection? {
        if let preferredSelection,
           let matchingCard = columns
            .flatMap(\.windows)
            .first(where: { $0.window.id == preferredSelection.windowID }) {
            return QuickSwitchSelection(
                appGroupIndex: matchingCard.appGroupIndex,
                windowIndex: matchingCard.windowIndex,
                windowID: matchingCard.window.id
            )
        }
        guard let firstCard = columns.first?.windows.first else { return nil }
        return QuickSwitchSelection(
            appGroupIndex: firstCard.appGroupIndex,
            windowIndex: firstCard.windowIndex,
            windowID: firstCard.window.id
        )
    }
    private static 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
    }
}