| | |
| | | import CoreGraphics |
| | | import Foundation |
| | | |
| | | public struct QuickSwitchViewModel: Equatable, Sendable { |
| | |
| | | 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 { |
| | |
| | | public let windowCount: Int |
| | | public let appCount: Int |
| | | public let appNames: [String] |
| | | public let splitViewAppNames: [String] |
| | | |
| | | public init( |
| | | id: UInt64, |
| | |
| | | isCurrent: Bool = false, |
| | | windowCount: Int, |
| | | appCount: Int, |
| | | appNames: [String] = [] |
| | | appNames: [String] = [], |
| | | splitViewAppNames: [String] = [] |
| | | ) { |
| | | self.id = id |
| | | self.displayUUID = displayUUID |
| | |
| | | self.windowCount = windowCount |
| | | self.appCount = appCount |
| | | self.appNames = appNames |
| | | self.splitViewAppNames = splitViewAppNames |
| | | } |
| | | } |
| | | |
| | |
| | | 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) |
| | | |
| | |
| | | displays: displays, |
| | | appShelf: appShelf, |
| | | waterfallColumns: columns, |
| | | initialSelection: QuickSwitchFocusPolicy.initialSelection(in: snapshot) |
| | | initialSelection: QuickSwitchFocusPolicy.initialSelection(in: snapshot), |
| | | lockedSpaceID: nil |
| | | ) |
| | | } |
| | | |
| | |
| | | currentSpaceIDs: Set<UInt64>, |
| | | windowCountsBySpace: [UInt64: Int], |
| | | appCountsBySpace: [UInt64: Int], |
| | | appNamesBySpace: [UInt64: [String]] |
| | | appNamesBySpace: [UInt64: [String]], |
| | | splitViewAppNamesBySpace: [UInt64: [String]] |
| | | ) -> [QuickSwitchDisplayViewModel] { |
| | | displays |
| | | let sortedDisplays = displays |
| | | .sorted { $0.uuid.localizedStandardCompare($1.uuid) == .orderedAscending } |
| | | let displayCount = sortedDisplays.count |
| | | |
| | | return sortedDisplays |
| | | .enumerated() |
| | | .map { displayIndex, display in |
| | | let displayLabel = displayLabel(for: displayIndex) |
| | |
| | | id: space.id, |
| | | displayUUID: space.displayUUID, |
| | | index: space.index, |
| | | label: "\(displayLabel)\(space.index)", |
| | | label: spaceLabel(for: space, displayIndex: displayIndex, displayCount: displayCount), |
| | | type: space.type, |
| | | 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] ?? [] |
| | | ) |
| | | } |
| | | |
| | |
| | | |
| | | private static func labelsBySpaceID(from displays: [AlignerDisplay]) -> [UInt64: String] { |
| | | var labels: [UInt64: String] = [:] |
| | | |
| | | for (displayIndex, display) in displays |
| | | let sortedDisplays = displays |
| | | .sorted(by: { $0.uuid.localizedStandardCompare($1.uuid) == .orderedAscending }) |
| | | .enumerated() |
| | | { |
| | | let displayLabel = displayLabel(for: displayIndex) |
| | | let displayCount = sortedDisplays.count |
| | | |
| | | for (displayIndex, display) in sortedDisplays.enumerated() { |
| | | for space in display.spaces { |
| | | labels[space.id] = "\(displayLabel)\(space.index)" |
| | | labels[space.id] = spaceLabel(for: space, displayIndex: displayIndex, displayCount: displayCount) |
| | | } |
| | | } |
| | | |
| | |
| | | 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] = [] |
| | |
| | | |
| | | return String(String.UnicodeScalarView(scalars)) |
| | | } |
| | | |
| | | private static func spaceLabel(for space: AlignerSpace, displayIndex: Int, displayCount: Int) -> String { |
| | | if displayCount <= 1 { |
| | | return String(format: "%02d", space.index) |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | } |