import CoreGraphics
|
import Foundation
|
|
public struct QuickSwitchViewModel: Equatable, Sendable {
|
public let displays: [QuickSwitchDisplayViewModel]
|
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?,
|
lockedSpaceID: UInt64? = nil
|
) {
|
self.displays = displays
|
self.appShelf = appShelf
|
self.waterfallColumns = waterfallColumns
|
self.initialSelection = initialSelection
|
self.lockedSpaceID = lockedSpaceID
|
}
|
|
public var spaceCount: Int {
|
displays.flatMap(\.spaces).count
|
}
|
|
public var windowCount: Int {
|
waterfallColumns.flatMap(\.windows).count
|
}
|
}
|
|
public struct QuickSwitchDisplayViewModel: Equatable, Sendable {
|
public let displayUUID: String
|
public let displayIndex: Int
|
public let displayLabel: String
|
public let physical: Bool
|
public let spaces: [QuickSwitchSpaceViewModel]
|
|
public init(
|
displayUUID: String,
|
displayIndex: Int,
|
displayLabel: String,
|
physical: Bool,
|
spaces: [QuickSwitchSpaceViewModel]
|
) {
|
self.displayUUID = displayUUID
|
self.displayIndex = displayIndex
|
self.displayLabel = displayLabel
|
self.physical = physical
|
self.spaces = spaces
|
}
|
}
|
|
public struct QuickSwitchSpaceViewModel: Equatable, Sendable {
|
public let id: UInt64
|
public let displayUUID: String
|
public let index: Int
|
public let label: String
|
public let type: AlignerSpaceType
|
public let isCurrent: Bool
|
public let windowCount: Int
|
public let appCount: Int
|
public let appNames: [String]
|
public let splitViewAppNames: [String]
|
|
public init(
|
id: UInt64,
|
displayUUID: String,
|
index: Int,
|
label: String,
|
type: AlignerSpaceType,
|
isCurrent: Bool = false,
|
windowCount: Int,
|
appCount: Int,
|
appNames: [String] = [],
|
splitViewAppNames: [String] = []
|
) {
|
self.id = id
|
self.displayUUID = displayUUID
|
self.index = index
|
self.label = label
|
self.type = type
|
self.isCurrent = isCurrent
|
self.windowCount = windowCount
|
self.appCount = appCount
|
self.appNames = appNames
|
self.splitViewAppNames = splitViewAppNames
|
}
|
}
|
|
public struct QuickSwitchAppShelfItemViewModel: Equatable, Sendable {
|
public let appGroupIndex: Int
|
public let app: AlignerApp
|
public let windowCount: Int
|
public let primarySpaceIDs: [UInt64]
|
public let hasMinimizedWindows: Bool
|
public let hasFullscreenWindows: Bool
|
|
public init(
|
appGroupIndex: Int,
|
app: AlignerApp,
|
windowCount: Int,
|
primarySpaceIDs: [UInt64],
|
hasMinimizedWindows: Bool,
|
hasFullscreenWindows: Bool
|
) {
|
self.appGroupIndex = appGroupIndex
|
self.app = app
|
self.windowCount = windowCount
|
self.primarySpaceIDs = primarySpaceIDs
|
self.hasMinimizedWindows = hasMinimizedWindows
|
self.hasFullscreenWindows = hasFullscreenWindows
|
}
|
}
|
|
public struct QuickSwitchWaterfallColumnViewModel: Equatable, Sendable {
|
public let appGroupIndex: Int
|
public let app: AlignerApp
|
public let windows: [QuickSwitchWindowCardViewModel]
|
|
public init(
|
appGroupIndex: Int,
|
app: AlignerApp,
|
windows: [QuickSwitchWindowCardViewModel]
|
) {
|
self.appGroupIndex = appGroupIndex
|
self.app = app
|
self.windows = windows
|
}
|
}
|
|
public struct QuickSwitchWindowCardViewModel: Equatable, Sendable {
|
public let appGroupIndex: Int
|
public let windowIndex: Int
|
public let globalIndex: Int
|
public let window: AlignerWindow
|
public let primarySpaceID: UInt64?
|
public let primarySpaceLabel: String?
|
|
public init(
|
appGroupIndex: Int,
|
windowIndex: Int,
|
globalIndex: Int,
|
window: AlignerWindow,
|
primarySpaceID: UInt64?,
|
primarySpaceLabel: String?
|
) {
|
self.appGroupIndex = appGroupIndex
|
self.windowIndex = windowIndex
|
self.globalIndex = globalIndex
|
self.window = window
|
self.primarySpaceID = primarySpaceID
|
self.primarySpaceLabel = primarySpaceLabel
|
}
|
}
|
|
public enum QuickSwitchViewModelBuilder {
|
public static func viewModel(from snapshot: QuickSwitchSnapshot) -> QuickSwitchViewModel {
|
viewModel(from: snapshot, currentSpaceIDs: [])
|
}
|
|
public static func viewModel(
|
from snapshot: QuickSwitchSnapshot,
|
currentSpaceIDs: Set<UInt64>
|
) -> QuickSwitchViewModel {
|
let spaceLabels = labelsBySpaceID(from: snapshot.displays)
|
let windowItems = snapshot.appGroups.flatMap(\.windows)
|
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,
|
splitViewAppNamesBySpace: splitViewAppNamesBySpace
|
)
|
let appShelf = appShelfViewModels(from: snapshot.appGroups)
|
|
var globalIndex = 0
|
let columns = snapshot.appGroups.enumerated().map { appGroupIndex, group in
|
let windows = group.windows.enumerated().map { windowIndex, item in
|
defer { globalIndex += 1 }
|
return QuickSwitchWindowCardViewModel(
|
appGroupIndex: appGroupIndex,
|
windowIndex: windowIndex,
|
globalIndex: globalIndex,
|
window: item.window,
|
primarySpaceID: item.primarySpaceID,
|
primarySpaceLabel: item.primarySpaceID.flatMap { spaceLabels[$0] }
|
)
|
}
|
|
return QuickSwitchWaterfallColumnViewModel(
|
appGroupIndex: appGroupIndex,
|
app: group.app,
|
windows: windows
|
)
|
}
|
|
return QuickSwitchViewModel(
|
displays: displays,
|
appShelf: appShelf,
|
waterfallColumns: columns,
|
initialSelection: QuickSwitchFocusPolicy.initialSelection(in: snapshot),
|
lockedSpaceID: nil
|
)
|
}
|
|
private static func displayViewModels(
|
from displays: [AlignerDisplay],
|
currentSpaceIDs: Set<UInt64>,
|
windowCountsBySpace: [UInt64: Int],
|
appCountsBySpace: [UInt64: Int],
|
appNamesBySpace: [UInt64: [String]],
|
splitViewAppNamesBySpace: [UInt64: [String]]
|
) -> [QuickSwitchDisplayViewModel] {
|
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)
|
let spaces = display.spaces
|
.sorted { $0.index < $1.index }
|
.map { space in
|
QuickSwitchSpaceViewModel(
|
id: space.id,
|
displayUUID: space.displayUUID,
|
index: 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] ?? [],
|
splitViewAppNames: splitViewAppNamesBySpace[space.id] ?? []
|
)
|
}
|
|
return QuickSwitchDisplayViewModel(
|
displayUUID: display.uuid,
|
displayIndex: displayIndex,
|
displayLabel: displayLabel,
|
physical: display.physical,
|
spaces: spaces
|
)
|
}
|
}
|
|
private static func appShelfViewModels(
|
from appGroups: [QuickSwitchAppGroup]
|
) -> [QuickSwitchAppShelfItemViewModel] {
|
appGroups.enumerated().map { appGroupIndex, group in
|
QuickSwitchAppShelfItemViewModel(
|
appGroupIndex: appGroupIndex,
|
app: group.app,
|
windowCount: group.windows.count,
|
primarySpaceIDs: orderedUnique(group.windows.compactMap(\.primarySpaceID)),
|
hasMinimizedWindows: group.windows.contains { $0.window.isMinimized },
|
hasFullscreenWindows: group.windows.contains { $0.window.isFullscreen }
|
)
|
}
|
}
|
|
private static func labelsBySpaceID(from displays: [AlignerDisplay]) -> [UInt64: String] {
|
var labels: [UInt64: String] = [:]
|
let sortedDisplays = displays
|
.sorted(by: { $0.uuid.localizedStandardCompare($1.uuid) == .orderedAscending })
|
let displayCount = sortedDisplays.count
|
|
for (displayIndex, display) in sortedDisplays.enumerated() {
|
for space in display.spaces {
|
labels[space.id] = spaceLabel(for: space, displayIndex: displayIndex, displayCount: displayCount)
|
}
|
}
|
|
return labels
|
}
|
|
private static func countWindowsBySpace(_ windowItems: [QuickSwitchWindowItem]) -> [UInt64: Int] {
|
var counts: [UInt64: Int] = [:]
|
for item in windowItems {
|
guard let primarySpaceID = item.primarySpaceID else { continue }
|
counts[primarySpaceID, default: 0] += 1
|
}
|
return counts
|
}
|
|
private static func countAppsBySpace(_ appGroups: [QuickSwitchAppGroup]) -> [UInt64: Int] {
|
var appIdentitiesBySpace: [UInt64: Set<Int>] = [:]
|
|
for (appGroupIndex, group) in appGroups.enumerated() {
|
for spaceID in orderedUnique(group.windows.compactMap(\.primarySpaceID)) {
|
appIdentitiesBySpace[spaceID, default: []].insert(appGroupIndex)
|
}
|
}
|
|
return appIdentitiesBySpace.mapValues(\.count)
|
}
|
|
private static func appNamesBySpace(_ appGroups: [QuickSwitchAppGroup]) -> [UInt64: [String]] {
|
var namesBySpace: [UInt64: [String]] = [:]
|
|
for group in appGroups {
|
for spaceID in orderedUnique(group.windows.compactMap(\.primarySpaceID)) {
|
namesBySpace[spaceID, default: []].append(group.app.name)
|
}
|
}
|
|
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] = []
|
|
for value in values where !seen.contains(value) {
|
seen.insert(value)
|
result.append(value)
|
}
|
|
return result
|
}
|
|
private static func displayLabel(for index: Int) -> String {
|
let scalarA = Int(UnicodeScalar("A").value)
|
var value = index
|
var scalars: [UnicodeScalar] = []
|
|
repeat {
|
let remainder = value % 26
|
scalars.insert(UnicodeScalar(scalarA + remainder)!, at: 0)
|
value = value / 26 - 1
|
} while value >= 0
|
|
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
|
}
|
}
|