| | |
| | | @MainActor |
| | | final class QuickSwitchRootView: NSView { |
| | | private static let appShelfIndexSymbols = Array("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ").map(String.init) |
| | | private static let appShelfIndexKeyCodes: [String: UInt16] = [ |
| | | "1": 18, "2": 19, "3": 20, "4": 21, "5": 23, |
| | | "6": 22, "7": 26, "8": 28, "9": 25, "0": 29, |
| | | "A": 0, "B": 11, "C": 8, "D": 2, "E": 14, "F": 3, |
| | | "G": 5, "H": 4, "I": 34, "J": 38, "K": 40, "L": 37, |
| | | "M": 46, "N": 45, "O": 31, "P": 35, "Q": 12, "R": 15, |
| | | "S": 1, "T": 17, "U": 32, "V": 9, "W": 13, "X": 7, |
| | | "Y": 16, "Z": 6 |
| | | ] |
| | | |
| | | private let backdropBlurView = NSVisualEffectView() |
| | | private let layerHostView = NSView() |
| | |
| | | private var keyboardCommandsApplied: [String] = [] |
| | | private var lastKeyboardCommand: String? |
| | | private var lastKeyboardAppIndexCommand: String? |
| | | private var appShelfIndexKeyDownCount = 0 |
| | | private var lastAppShelfIndexKeySymbol: String? |
| | | private var lastBoundaryBounceAxis: String? |
| | | private var lastBoundaryBounceDirection: String? |
| | | private var boundaryBounceCount = 0 |
| | |
| | | case event |
| | | case debug |
| | | case keyboard |
| | | } |
| | | |
| | | private enum HorizontalKeyboardFocusKind: String { |
| | | case app |
| | | case window |
| | | } |
| | | |
| | | private struct HorizontalKeyboardFocusNode { |
| | | let kind: HorizontalKeyboardFocusKind |
| | | let appGroupIndex: Int |
| | | let windowIndex: Int? |
| | | let windowID: UInt32? |
| | | let spaceID: UInt64? |
| | | let frame: CGRect |
| | | let order: Int |
| | | |
| | | var center: CGPoint { |
| | | CGPoint(x: frame.midX, y: frame.midY) |
| | | } |
| | | |
| | | func matches(_ other: HorizontalKeyboardFocusNode) -> Bool { |
| | | kind == other.kind |
| | | && appGroupIndex == other.appGroupIndex |
| | | && windowIndex == other.windowIndex |
| | | && windowID == other.windowID |
| | | } |
| | | } |
| | | |
| | | private struct SpaceLaneSegmentLayers { |
| | |
| | | layoutSubtreeIfNeeded() |
| | | for command in commands { |
| | | let measurementStart = CACurrentMediaTime() |
| | | performKeyboardCommand(command) |
| | | if !performDebugPhysicalIndexKeyCommand(command) { |
| | | performKeyboardCommand(command) |
| | | } |
| | | layoutSubtreeIfNeeded() |
| | | CATransaction.flush() |
| | | keyboardResponseLatencyMilliseconds.append(milliseconds(since: measurementStart)) |
| | | } |
| | | } |
| | | |
| | | private func performDebugPhysicalIndexKeyCommand(_ rawCommand: String) -> Bool { |
| | | let command = rawCommand.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | let uppercased = command.uppercased() |
| | | let prefixes = ["PHYSICAL-INDEX:", "PHYSICAL-INDEX-", "KEYDOWN:", "KEYDOWN-"] |
| | | guard let prefix = prefixes.first(where: { uppercased.hasPrefix($0) }) else { |
| | | return false |
| | | } |
| | | |
| | | let symbolStart = command.index(command.startIndex, offsetBy: prefix.count) |
| | | guard let symbol = appShelfIndexSymbol(fromKeyboardCommand: String(command[symbolStart...])), |
| | | let keyCode = Self.appShelfIndexKeyCodes[symbol], |
| | | let event = NSEvent.keyEvent( |
| | | with: .keyDown, |
| | | location: .zero, |
| | | modifierFlags: [.option], |
| | | timestamp: ProcessInfo.processInfo.systemUptime, |
| | | windowNumber: window?.windowNumber ?? 0, |
| | | context: nil, |
| | | characters: symbol.lowercased(), |
| | | charactersIgnoringModifiers: symbol.lowercased(), |
| | | isARepeat: false, |
| | | keyCode: keyCode |
| | | ) |
| | | else { |
| | | recordKeyboardCommand("invalidPhysicalIndex:\(command)") |
| | | selectionChangedByLastCommand = false |
| | | return true |
| | | } |
| | | |
| | | let previousSuppressEventInput = suppressEventInput |
| | | suppressEventInput = false |
| | | keyDown(with: event) |
| | | suppressEventInput = previousSuppressEventInput |
| | | return true |
| | | } |
| | | |
| | | func performDebugMouseCommands(_ commands: [String]) { |
| | |
| | | "selectedWindowID": effectiveSelection?.windowID ?? NSNull(), |
| | | "keyboardFocusedAppGroupIndex": keyboardFocusedAppGroupIndex ?? NSNull(), |
| | | "keyboardFocusedWindowID": keyboardFocusedWindowID ?? NSNull(), |
| | | "horizontalAppDefaultWindowID": horizontalAppDefaultWindowID ?? NSNull(), |
| | | "keyboardCommandsApplied": keyboardCommandsApplied, |
| | | "lastKeyboardCommand": lastKeyboardCommand ?? NSNull(), |
| | | "lastKeyboardAppIndexCommand": lastKeyboardAppIndexCommand ?? NSNull(), |
| | | "appShelfIndexKeyDownCount": appShelfIndexKeyDownCount, |
| | | "lastAppShelfIndexKeySymbol": lastAppShelfIndexKeySymbol ?? NSNull(), |
| | | "lastBoundaryBounceAxis": lastBoundaryBounceAxis ?? NSNull(), |
| | | "lastBoundaryBounceDirection": lastBoundaryBounceDirection ?? NSNull(), |
| | | "boundaryBounceCount": boundaryBounceCount, |
| | |
| | | case "tab": |
| | | if isHorizontalMasonryMode { |
| | | recordKeyboardCommand("tab") |
| | | moveSelection(.right) |
| | | moveHorizontalMasonryTabSelection() |
| | | } else { |
| | | tabIgnoredCount += 1 |
| | | recordKeyboardCommand("tabIgnored") |
| | |
| | | } |
| | | |
| | | private func handleAppShelfIndexKey(_ event: NSEvent) -> Bool { |
| | | guard !isHorizontalMasonryMode else { return false } |
| | | let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) |
| | | guard flags.isDisjoint(with: [.command, .control]) else { return false } |
| | | guard let characters = event.charactersIgnoringModifiers, |
| | |
| | | return false |
| | | } |
| | | |
| | | appShelfIndexKeyDownCount += 1 |
| | | lastAppShelfIndexKeySymbol = symbol |
| | | performKeyboardCommand(symbol) |
| | | return true |
| | | } |
| | |
| | | clearKeyboardBoundaryFeedback() |
| | | ensureAppShelfItemVisible(appGroupIndex) |
| | | layoutSubtreeIfNeeded() |
| | | if isHorizontalMasonryMode { |
| | | _ = selectHorizontalMasonryDefaultWindowForApp(appGroupIndex) |
| | | } |
| | | hoverAppGroup(appGroupIndex, source: .keyboard) |
| | | needsLayout = true |
| | | return true |
| | |
| | | private var appHoverTargetAppGroupIndex: Int? { |
| | | guard case .app(let appGroupIndex) = hoverTarget else { return nil } |
| | | return appGroupIndex |
| | | } |
| | | |
| | | private var horizontalAppDefaultWindowID: UInt32? { |
| | | guard isHorizontalMasonryMode, |
| | | case .app(let appGroupIndex) = hoverTarget |
| | | else { |
| | | return nil |
| | | } |
| | | return firstHorizontalMasonryCard(for: appGroupIndex)?.item.window.id |
| | | } |
| | | |
| | | private func firstHorizontalMasonryCard(for appGroupIndex: Int) -> WaterfallCardLayers? { |
| | | waterfallColumns |
| | | .first { $0.column.appGroupIndex == appGroupIndex }? |
| | | .cards |
| | | .sorted { $0.item.windowIndex < $1.item.windowIndex } |
| | | .first |
| | | } |
| | | |
| | | private func isHorizontalAppDefaultCard(_ card: WaterfallCardLayers) -> Bool { |
| | | guard isHorizontalMasonryMode, |
| | | card.item.window.id == horizontalAppDefaultWindowID |
| | | else { |
| | | return false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | @discardableResult |
| | | private func selectHorizontalMasonryDefaultWindowForApp( |
| | | _ appGroupIndex: Int, |
| | | recordsSelectionChange: Bool = false |
| | | ) -> QuickSwitchSelection? { |
| | | guard isHorizontalMasonryMode, |
| | | let card = firstHorizontalMasonryCard(for: appGroupIndex) |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | let previousSelection = effectiveSelection |
| | | let selection = selectionForCard(card) |
| | | currentSelection = selection |
| | | columnSelectionHistory[selection.appGroupIndex] = selection.windowIndex |
| | | if recordsSelectionChange { |
| | | selectionChangedByLastCommand = previousSelection != selection |
| | | } |
| | | return selection |
| | | } |
| | | |
| | | private func moveVerticalSelectionWithinFocusedColumn(_ direction: QuickSwitchKeyboardDirection) { |
| | |
| | | hoveredSpaceID = target.spaceID |
| | | hoveredSpaceLaneID = target.spaceLaneID |
| | | |
| | | if case .app(let appGroupIndex) = target, isHorizontalMasonryMode { |
| | | keyboardFocusedWindowID = nil |
| | | _ = selectHorizontalMasonryDefaultWindowForApp(appGroupIndex) |
| | | } |
| | | |
| | | if alignsWaterfallWithAppShelf, let appGroupIndex = target.appGroupIndex { |
| | | alignWaterfallColumnWithAppShelfIcon(appGroupIndex: appGroupIndex, animated: true) |
| | | } |
| | |
| | | } |
| | | |
| | | private func moveHorizontalMasonrySelection(_ direction: QuickSwitchKeyboardDirection) { |
| | | guard let current = currentSelection ?? currentViewModel?.initialSelection, |
| | | let next = nextHorizontalMasonrySelection(from: current, direction: direction) |
| | | layoutSubtreeIfNeeded() |
| | | |
| | | let nodes = horizontalKeyboardFocusNodes() |
| | | guard !nodes.isEmpty, |
| | | let current = currentHorizontalKeyboardFocusNode(in: nodes), |
| | | let next = nextHorizontalKeyboardFocusNode(from: current, direction: direction, in: nodes) |
| | | else { |
| | | DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection.blocked", [ |
| | | "direction": String(describing: direction), |
| | | "hasNodes": !nodes.isEmpty, |
| | | "hasSelection": (currentSelection ?? currentViewModel?.initialSelection) != nil |
| | | ]) |
| | | selectionChangedByLastCommand = false |
| | | return |
| | | } |
| | | |
| | | focusHorizontalKeyboardNode(next, direction: direction) |
| | | DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection", [ |
| | | "direction": String(describing: direction), |
| | | "kind": next.kind.rawValue, |
| | | "appGroupIndex": next.appGroupIndex, |
| | | "windowIndex": next.windowIndex ?? -1, |
| | | "windowID": next.windowID ?? 0 |
| | | ]) |
| | | } |
| | | |
| | | private func moveHorizontalMasonryTabSelection() { |
| | | guard let current = currentSelection ?? currentViewModel?.initialSelection, |
| | | let next = nextHorizontalMasonrySelection(from: current, direction: .right) |
| | | else { |
| | | DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonryTabSelection.blocked", [ |
| | | "hasSelection": (currentSelection ?? currentViewModel?.initialSelection) != nil |
| | | ]) |
| | | selectionChangedByLastCommand = false |
| | | return |
| | | } |
| | | |
| | | columnSelectionHistory[current.appGroupIndex] = current.windowIndex |
| | | let previousSelection = effectiveSelection |
| | | currentSelection = next |
| | | keyboardFocusedAppGroupIndex = next.appGroupIndex |
| | | keyboardFocusedWindowID = next.windowID |
| | | columnSelectionHistory[next.appGroupIndex] = next.windowIndex |
| | | selectionChangedByLastCommand = previousSelection != next |
| | | clearKeyboardBoundaryFeedback() |
| | | setHoverTarget( |
| | | .window(appGroupIndex: next.appGroupIndex, windowID: next.windowID, spaceID: nil), |
| | | source: .keyboard |
| | | ) |
| | | ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true)) |
| | | DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection", [ |
| | | "direction": String(describing: direction), |
| | | DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonryTabSelection", [ |
| | | "appGroupIndex": next.appGroupIndex, |
| | | "windowIndex": next.windowIndex, |
| | | "windowID": next.windowID |
| | | ]) |
| | | needsLayout = true |
| | | } |
| | | |
| | | private func horizontalKeyboardFocusNodes() -> [HorizontalKeyboardFocusNode] { |
| | | var nodes: [HorizontalKeyboardFocusNode] = [] |
| | | nodes.reserveCapacity(appShelfItems.count + waterfallColumns.reduce(0) { $0 + $1.cards.count }) |
| | | |
| | | for (index, item) in appShelfItems.enumerated() { |
| | | let frame = item.iconLayer.frame.offsetBy( |
| | | dx: shelfLayer.frame.minX |
| | | + appShelfContentLayer.frame.minX |
| | | + item.containerLayer.frame.minX, |
| | | dy: shelfLayer.frame.minY |
| | | + appShelfContentLayer.frame.minY |
| | | + item.containerLayer.frame.minY |
| | | ) |
| | | nodes.append(HorizontalKeyboardFocusNode( |
| | | kind: .app, |
| | | appGroupIndex: item.item.appGroupIndex, |
| | | windowIndex: nil, |
| | | windowID: nil, |
| | | spaceID: nil, |
| | | frame: frame, |
| | | order: index |
| | | )) |
| | | } |
| | | |
| | | var order = appShelfItems.count |
| | | for column in waterfallColumns.sorted(by: { $0.column.appGroupIndex < $1.column.appGroupIndex }) { |
| | | for card in column.cards.sorted(by: { $0.item.windowIndex < $1.item.windowIndex }) { |
| | | let frame = card.containerLayer.frame.offsetBy( |
| | | dx: waterfallLayer.frame.minX |
| | | + waterfallContentLayer.frame.minX |
| | | + column.containerLayer.frame.minX |
| | | + column.cardsClipLayer.frame.minX, |
| | | dy: waterfallLayer.frame.minY |
| | | + waterfallContentLayer.frame.minY |
| | | + column.containerLayer.frame.minY |
| | | + column.cardsClipLayer.frame.minY |
| | | ) |
| | | nodes.append(HorizontalKeyboardFocusNode( |
| | | kind: .window, |
| | | appGroupIndex: card.item.appGroupIndex, |
| | | windowIndex: card.item.windowIndex, |
| | | windowID: card.item.window.id, |
| | | spaceID: card.item.primarySpaceID, |
| | | frame: frame, |
| | | order: order |
| | | )) |
| | | order += 1 |
| | | } |
| | | } |
| | | |
| | | return nodes |
| | | } |
| | | |
| | | private func currentHorizontalKeyboardFocusNode( |
| | | in nodes: [HorizontalKeyboardFocusNode] |
| | | ) -> HorizontalKeyboardFocusNode? { |
| | | if case .app(let appGroupIndex) = hoverTarget, |
| | | let node = nodes.first(where: { $0.kind == .app && $0.appGroupIndex == appGroupIndex }) { |
| | | return node |
| | | } |
| | | |
| | | if let keyboardFocusedWindowID, |
| | | let node = nodes.first(where: { $0.kind == .window && $0.windowID == keyboardFocusedWindowID }) { |
| | | return node |
| | | } |
| | | |
| | | if case .window(_, let windowID, _) = hoverTarget, |
| | | let node = nodes.first(where: { $0.kind == .window && $0.windowID == windowID }) { |
| | | return node |
| | | } |
| | | |
| | | if let selection = effectiveSelection, |
| | | let node = nodes.first(where: { $0.kind == .window && $0.windowID == selection.windowID }) { |
| | | return node |
| | | } |
| | | |
| | | return nodes.min { $0.order < $1.order } |
| | | } |
| | | |
| | | private func nextHorizontalKeyboardFocusNode( |
| | | from current: HorizontalKeyboardFocusNode, |
| | | direction: QuickSwitchKeyboardDirection, |
| | | in nodes: [HorizontalKeyboardFocusNode] |
| | | ) -> HorizontalKeyboardFocusNode? { |
| | | let candidates = nodes.filter { !$0.matches(current) } |
| | | guard !candidates.isEmpty else { return current } |
| | | |
| | | if current.kind == .app, |
| | | direction == .down, |
| | | let firstWindow = firstHorizontalWindowNode(for: current.appGroupIndex, in: nodes) { |
| | | return firstWindow |
| | | } |
| | | |
| | | if current.kind == .window, |
| | | direction == .up, |
| | | current.windowID == firstHorizontalMasonryCard(for: current.appGroupIndex)?.item.window.id, |
| | | let appNode = nodes.first(where: { $0.kind == .app && $0.appGroupIndex == current.appGroupIndex }) { |
| | | return appNode |
| | | } |
| | | |
| | | switch direction { |
| | | case .left, .right: |
| | | let sameKindCandidates = candidates.filter { $0.kind == current.kind } |
| | | return nextHorizontalFocusByX( |
| | | from: current, |
| | | direction: direction, |
| | | candidates: sameKindCandidates.isEmpty ? candidates : sameKindCandidates |
| | | ) |
| | | case .up, .down: |
| | | return nextHorizontalFocusByY(from: current, direction: direction, candidates: candidates) |
| | | } |
| | | } |
| | | |
| | | private func firstHorizontalWindowNode( |
| | | for appGroupIndex: Int, |
| | | in nodes: [HorizontalKeyboardFocusNode] |
| | | ) -> HorizontalKeyboardFocusNode? { |
| | | nodes |
| | | .filter { $0.kind == .window && $0.appGroupIndex == appGroupIndex } |
| | | .min { lhs, rhs in |
| | | (lhs.windowIndex ?? .max, lhs.order) < (rhs.windowIndex ?? .max, rhs.order) |
| | | } |
| | | } |
| | | |
| | | private func nextHorizontalFocusByX( |
| | | from current: HorizontalKeyboardFocusNode, |
| | | direction: QuickSwitchKeyboardDirection, |
| | | candidates: [HorizontalKeyboardFocusNode] |
| | | ) -> HorizontalKeyboardFocusNode? { |
| | | let epsilon: CGFloat = 0.5 |
| | | let forward = direction == .right |
| | | let directional = candidates.filter { node in |
| | | forward |
| | | ? node.center.x > current.center.x + epsilon |
| | | : node.center.x < current.center.x - epsilon |
| | | } |
| | | let sameBand = directional.filter { isHorizontalFocusSameBand($0, current) } |
| | | let pool = sameBand.isEmpty ? directional : sameBand |
| | | if let next = pool.sorted(by: horizontalFocusXSort(from: current, forward: forward)).first { |
| | | return next |
| | | } |
| | | |
| | | let wrapSameBand = candidates.filter { isHorizontalFocusSameBand($0, current) } |
| | | let wrapPool = wrapSameBand.isEmpty ? candidates : wrapSameBand |
| | | return wrapPool.sorted(by: horizontalFocusXWrapSort(from: current, forward: forward)).first |
| | | } |
| | | |
| | | private func nextHorizontalFocusByY( |
| | | from current: HorizontalKeyboardFocusNode, |
| | | direction: QuickSwitchKeyboardDirection, |
| | | candidates: [HorizontalKeyboardFocusNode] |
| | | ) -> HorizontalKeyboardFocusNode? { |
| | | let epsilon: CGFloat = 0.5 |
| | | let upward = direction == .up |
| | | let directional = candidates.filter { node in |
| | | upward |
| | | ? node.center.y > current.center.y + epsilon |
| | | : node.center.y < current.center.y - epsilon |
| | | } |
| | | if let next = directional.sorted(by: horizontalFocusYSort(from: current, upward: upward)).first { |
| | | return next |
| | | } |
| | | return candidates.sorted(by: horizontalFocusYWrapSort(from: current, upward: upward)).first |
| | | } |
| | | |
| | | private func isHorizontalFocusSameBand( |
| | | _ lhs: HorizontalKeyboardFocusNode, |
| | | _ rhs: HorizontalKeyboardFocusNode |
| | | ) -> Bool { |
| | | let tolerance = max(lhs.frame.height, rhs.frame.height) * 0.75 |
| | | return abs(lhs.center.y - rhs.center.y) <= tolerance |
| | | } |
| | | |
| | | private func horizontalFocusXSort( |
| | | from current: HorizontalKeyboardFocusNode, |
| | | forward: Bool |
| | | ) -> (HorizontalKeyboardFocusNode, HorizontalKeyboardFocusNode) -> Bool { |
| | | { lhs, rhs in |
| | | let lhsPrimary = abs(lhs.center.x - current.center.x) |
| | | let rhsPrimary = abs(rhs.center.x - current.center.x) |
| | | if abs(lhsPrimary - rhsPrimary) > 0.5 { |
| | | return lhsPrimary < rhsPrimary |
| | | } |
| | | |
| | | let lhsSecondary = abs(lhs.center.y - current.center.y) |
| | | let rhsSecondary = abs(rhs.center.y - current.center.y) |
| | | if abs(lhsSecondary - rhsSecondary) > 0.5 { |
| | | return lhsSecondary < rhsSecondary |
| | | } |
| | | |
| | | return forward ? lhs.order < rhs.order : lhs.order > rhs.order |
| | | } |
| | | } |
| | | |
| | | private func horizontalFocusXWrapSort( |
| | | from current: HorizontalKeyboardFocusNode, |
| | | forward: Bool |
| | | ) -> (HorizontalKeyboardFocusNode, HorizontalKeyboardFocusNode) -> Bool { |
| | | { lhs, rhs in |
| | | if abs(lhs.center.x - rhs.center.x) > 0.5 { |
| | | return forward ? lhs.center.x < rhs.center.x : lhs.center.x > rhs.center.x |
| | | } |
| | | |
| | | let lhsSecondary = abs(lhs.center.y - current.center.y) |
| | | let rhsSecondary = abs(rhs.center.y - current.center.y) |
| | | if abs(lhsSecondary - rhsSecondary) > 0.5 { |
| | | return lhsSecondary < rhsSecondary |
| | | } |
| | | |
| | | return forward ? lhs.order < rhs.order : lhs.order > rhs.order |
| | | } |
| | | } |
| | | |
| | | private func horizontalFocusYSort( |
| | | from current: HorizontalKeyboardFocusNode, |
| | | upward: Bool |
| | | ) -> (HorizontalKeyboardFocusNode, HorizontalKeyboardFocusNode) -> Bool { |
| | | { lhs, rhs in |
| | | let lhsPrimary = abs(lhs.center.y - current.center.y) |
| | | let rhsPrimary = abs(rhs.center.y - current.center.y) |
| | | if abs(lhsPrimary - rhsPrimary) > 0.5 { |
| | | return lhsPrimary < rhsPrimary |
| | | } |
| | | |
| | | let lhsSecondary = abs(lhs.center.x - current.center.x) |
| | | let rhsSecondary = abs(rhs.center.x - current.center.x) |
| | | if abs(lhsSecondary - rhsSecondary) > 0.5 { |
| | | return lhsSecondary < rhsSecondary |
| | | } |
| | | |
| | | return upward ? lhs.order < rhs.order : lhs.order > rhs.order |
| | | } |
| | | } |
| | | |
| | | private func horizontalFocusYWrapSort( |
| | | from current: HorizontalKeyboardFocusNode, |
| | | upward: Bool |
| | | ) -> (HorizontalKeyboardFocusNode, HorizontalKeyboardFocusNode) -> Bool { |
| | | { lhs, rhs in |
| | | if abs(lhs.center.y - rhs.center.y) > 0.5 { |
| | | return upward ? lhs.center.y < rhs.center.y : lhs.center.y > rhs.center.y |
| | | } |
| | | |
| | | let lhsSecondary = abs(lhs.center.x - current.center.x) |
| | | let rhsSecondary = abs(rhs.center.x - current.center.x) |
| | | if abs(lhsSecondary - rhsSecondary) > 0.5 { |
| | | return lhsSecondary < rhsSecondary |
| | | } |
| | | |
| | | return upward ? lhs.order < rhs.order : lhs.order > rhs.order |
| | | } |
| | | } |
| | | |
| | | private func focusHorizontalKeyboardNode( |
| | | _ node: HorizontalKeyboardFocusNode, |
| | | direction _: QuickSwitchKeyboardDirection |
| | | ) { |
| | | switch node.kind { |
| | | case .app: |
| | | keyboardFocusedAppGroupIndex = node.appGroupIndex |
| | | keyboardFocusedWindowID = nil |
| | | clearKeyboardBoundaryFeedback() |
| | | ensureAppShelfItemVisible(node.appGroupIndex) |
| | | _ = selectHorizontalMasonryDefaultWindowForApp( |
| | | node.appGroupIndex, |
| | | recordsSelectionChange: true |
| | | ) |
| | | hoverAppGroup(node.appGroupIndex, source: .keyboard) |
| | | case .window: |
| | | guard let windowIndex = node.windowIndex, |
| | | let windowID = node.windowID |
| | | else { |
| | | selectionChangedByLastCommand = false |
| | | return |
| | | } |
| | | |
| | | let previousSelection = effectiveSelection |
| | | let next = QuickSwitchSelection( |
| | | appGroupIndex: node.appGroupIndex, |
| | | windowIndex: windowIndex, |
| | | windowID: windowID |
| | | ) |
| | | keyboardFocusedAppGroupIndex = node.appGroupIndex |
| | | keyboardFocusedWindowID = windowID |
| | | currentSelection = next |
| | | columnSelectionHistory[next.appGroupIndex] = next.windowIndex |
| | | selectionChangedByLastCommand = previousSelection != next |
| | | clearKeyboardBoundaryFeedback() |
| | | ensureAppShelfItemVisible(node.appGroupIndex) |
| | | setHoverTarget( |
| | | .window(appGroupIndex: node.appGroupIndex, windowID: windowID, spaceID: node.spaceID), |
| | | source: .keyboard |
| | | ) |
| | | ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true)) |
| | | } |
| | | |
| | | needsLayout = true |
| | | } |
| | | |
| | |
| | | let isSelected = card.item.window.id == effectiveSelection?.windowID |
| | | let isHovered = card.item.window.id == hoveredWindowID |
| | | let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID |
| | | let isHorizontalAppDefaultFocused = isHorizontalAppDefaultCard(card) |
| | | let isAppLinked = isWaterfallCardAppLinked(card) |
| | | applyWaterfallCardVisualState( |
| | | card, |
| | | selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused, |
| | | selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) |
| | | || isKeyboardFocused |
| | | || isHorizontalAppDefaultFocused, |
| | | hovered: isHovered, |
| | | appLinked: isAppLinked |
| | | ) |
| | |
| | | "identifierSource": "\(card.item.window.identifierSource)", |
| | | "isSelected": card.item.window.id == effectiveSelection?.windowID, |
| | | "isKeyboardFocused": card.item.window.id == keyboardFocusedWindowID, |
| | | "isHorizontalAppDefault": isHorizontalAppDefaultCard(card), |
| | | "visualStates": waterfallCardVisualStates(for: card), |
| | | "thumbnailStrategy": thumbnailStrategyString(for: card.item.window), |
| | | "screenshotSource": screenshotSourceString(screenshotSource, for: card.item.window), |
| | |
| | | "closeButtonGlyphLineWidth": Double(card.closeButton.glyphLayer.lineWidth), |
| | | "shineVisible": card.shineLayer.opacity > 0, |
| | | "zPosition": Double(card.containerLayer.zPosition), |
| | | "bounds": dictionary(from: card.containerLayer.bounds), |
| | | "frame": dictionary(from: card.containerLayer.frame), |
| | | "visibleFrame": dictionary(from: visibleFrame), |
| | | "titleTruncationMode": "middle" |
| | |
| | | let isSelected = card.item.window.id == effectiveSelection?.windowID |
| | | let isHovered = card.item.window.id == hoveredWindowID |
| | | let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID |
| | | let isHorizontalAppDefaultFocused = isHorizontalAppDefaultCard(card) |
| | | let isAppLinked = isWaterfallCardAppLinked(card) |
| | | if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused { |
| | | if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) |
| | | || isKeyboardFocused |
| | | || isHorizontalAppDefaultFocused { |
| | | states.append("selected") |
| | | } else if isSelected { |
| | | states.append("selectedVisualSuppressed") |
| | |
| | | if isKeyboardFocused { |
| | | states.append("keyboardFocused") |
| | | } |
| | | if isHorizontalAppDefaultFocused { |
| | | states.append("appDefault") |
| | | } |
| | | if isHovered { |
| | | states.append("hover") |
| | | } |