| | |
| | | private var currentSelection: QuickSwitchSelection? |
| | | private var columnSelectionHistory: [Int: Int] = [:] |
| | | private var keyboardFocusedAppGroupIndex: Int? |
| | | private var keyboardFocusedWindowID: UInt32? |
| | | private var keyboardCommandsApplied: [String] = [] |
| | | private var lastKeyboardCommand: String? |
| | | private var lastKeyboardAppIndexCommand: String? |
| | |
| | | private struct WaterfallColumnLayers { |
| | | let column: QuickSwitchWaterfallColumnViewModel |
| | | let containerLayer: CALayer |
| | | let cardsClipLayer: CALayer |
| | | let headerLayer: CALayer |
| | | let appNameLayer: CATextLayer |
| | | let countLayer: CATextLayer |
| | |
| | | static let cardGap: CGFloat = 12 |
| | | static let revealPadding: CGFloat = 24 |
| | | static let horizontalRevealPadding: CGFloat = 0 |
| | | static let selectedCardScale: CGFloat = 1.015 |
| | | static let selectedCardScaleOverflow: CGFloat = cardHeight * (selectedCardScale - 1) / 2 |
| | | } |
| | | |
| | | private enum HorizontalMasonryMetrics { |
| | |
| | | if case .realScreenshot = resolution.source { |
| | | let isSelected = card.item.window.id == effectiveSelection?.windowID |
| | | let isHovered = card.item.window.id == hoveredWindowID |
| | | card.shineLayer.opacity = shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) ? 1 : 0 |
| | | let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID |
| | | card.shineLayer.opacity = (shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused) ? 1 : 0 |
| | | } |
| | | needsLayout = true |
| | | } |
| | |
| | | viewModel?.waterfallColumns.contains(where: { $0.appGroupIndex == keyboardFocusedAppGroupIndex }) != true { |
| | | self.keyboardFocusedAppGroupIndex = nil |
| | | } |
| | | if let keyboardFocusedWindowID, |
| | | viewModel?.waterfallColumns.contains(where: { column in |
| | | column.windows.contains { $0.window.id == keyboardFocusedWindowID } |
| | | }) != true { |
| | | self.keyboardFocusedWindowID = nil |
| | | } |
| | | if let currentSelection { |
| | | columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex |
| | | } |
| | |
| | | currentSelection = viewModel?.initialSelection |
| | | columnSelectionHistory = [:] |
| | | keyboardFocusedAppGroupIndex = nil |
| | | keyboardFocusedWindowID = nil |
| | | keyboardCommandsApplied = [] |
| | | lastKeyboardCommand = nil |
| | | lastKeyboardAppIndexCommand = nil |
| | |
| | | "selectedWindowIndex": effectiveSelection?.windowIndex ?? NSNull(), |
| | | "selectedWindowID": effectiveSelection?.windowID ?? NSNull(), |
| | | "keyboardFocusedAppGroupIndex": keyboardFocusedAppGroupIndex ?? NSNull(), |
| | | "keyboardFocusedWindowID": keyboardFocusedWindowID ?? NSNull(), |
| | | "keyboardCommandsApplied": keyboardCommandsApplied, |
| | | "lastKeyboardCommand": lastKeyboardCommand ?? NSNull(), |
| | | "lastKeyboardAppIndexCommand": lastKeyboardAppIndexCommand ?? NSNull(), |
| | |
| | | } |
| | | |
| | | keyboardFocusedAppGroupIndex = appGroupIndex |
| | | keyboardFocusedWindowID = nil |
| | | lastKeyboardAppIndexCommand = appIndexCommand |
| | | selectionChangedByLastCommand = false |
| | | clearKeyboardBoundaryFeedback() |
| | |
| | | |
| | | keyboardFocusedAppGroupIndex = targetAppGroupIndex |
| | | currentSelection = nextSelection |
| | | keyboardFocusedWindowID = nextSelection.windowID |
| | | columnSelectionHistory[nextSelection.appGroupIndex] = nextSelection.windowIndex |
| | | selectionChangedByLastCommand = previousSelection != nextSelection |
| | | clearKeyboardBoundaryFeedback() |
| | |
| | | container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor |
| | | container.borderWidth = 1 |
| | | |
| | | let cardsClip = CALayer() |
| | | cardsClip.masksToBounds = true |
| | | cardsClip.backgroundColor = NSColor.clear.cgColor |
| | | |
| | | let header = CALayer() |
| | | header.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.68).cgColor |
| | | header.zPosition = 40 |
| | | |
| | | let appName = makeTextLayer( |
| | | string: column.app.name, |
| | |
| | | alignment: .right |
| | | ) |
| | | |
| | | appName.zPosition = 42 |
| | | count.zPosition = 42 |
| | | |
| | | container.addSublayer(cardsClip) |
| | | container.addSublayer(header) |
| | | container.addSublayer(appName) |
| | | container.addSublayer(count) |
| | |
| | | card.addSublayer(state) |
| | | card.addSublayer(shine) |
| | | card.addSublayer(closeButton.containerLayer) |
| | | container.addSublayer(card) |
| | | cardsClip.addSublayer(card) |
| | | |
| | | return WaterfallCardLayers( |
| | | item: window, |
| | |
| | | return WaterfallColumnLayers( |
| | | column: column, |
| | | containerLayer: container, |
| | | cardsClipLayer: cardsClip, |
| | | headerLayer: header, |
| | | appNameLayer: appName, |
| | | countLayer: count, |
| | |
| | | x: locationInWaterfallContent.x - column.containerLayer.frame.minX, |
| | | y: locationInWaterfallContent.y - column.containerLayer.frame.minY |
| | | ) |
| | | if let card = column.cards.first(where: { $0.containerLayer.frame.contains(locationInColumn) }) { |
| | | guard column.cardsClipLayer.frame.contains(locationInColumn) else { |
| | | continue |
| | | } |
| | | let locationInCardsClip = CGPoint( |
| | | x: locationInColumn.x - column.cardsClipLayer.frame.minX, |
| | | y: locationInColumn.y - column.cardsClipLayer.frame.minY |
| | | ) |
| | | if let card = column.cards.first(where: { $0.containerLayer.frame.contains(locationInCardsClip) }) { |
| | | return card |
| | | } |
| | | } |
| | |
| | | column.containerLayer.shadowOpacity = 0 |
| | | column.containerLayer.shadowRadius = 0 |
| | | column.containerLayer.shadowOffset = .zero |
| | | column.cardsClipLayer.frame = column.containerLayer.bounds |
| | | column.cardsClipLayer.masksToBounds = false |
| | | column.headerLayer.isHidden = true |
| | | column.appNameLayer.isHidden = true |
| | | column.countLayer.isHidden = true |
| | |
| | | column.countLayer.isHidden = false |
| | | |
| | | let bounds = column.containerLayer.bounds |
| | | let cardsClipFrame = CGRect( |
| | | x: 0, |
| | | y: 0, |
| | | width: bounds.width, |
| | | height: max(1, bounds.height - WaterfallMetrics.headerHeight) |
| | | ) |
| | | column.cardsClipLayer.frame = cardsClipFrame |
| | | column.cardsClipLayer.masksToBounds = true |
| | | let isSpaceFocused = spaceFocusWindowCount(for: column.column.appGroupIndex) > 0 |
| | | let isHovered = column.column.appGroupIndex == effectiveHoveredAppGroupIndex |
| | | column.containerLayer.borderColor = (isSpaceFocused |
| | |
| | | waterfallColumnScrollOffsets[appGroupIndex] = verticalOffset |
| | | |
| | | let cardWidth = max(1, bounds.width - WaterfallMetrics.columnHorizontalPadding * 2) |
| | | let firstCardTop = bounds.height |
| | | - WaterfallMetrics.headerHeight |
| | | let firstCardTop = cardsClipFrame.height |
| | | - WaterfallMetrics.selectedCardScaleOverflow |
| | | - WaterfallMetrics.columnVerticalPadding |
| | | + verticalOffset |
| | | |
| | |
| | | let bounds = card.containerLayer.bounds |
| | | let isSelected = card.item.window.id == effectiveSelection?.windowID |
| | | let isHovered = card.item.window.id == hoveredWindowID |
| | | let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID |
| | | let isAppLinked = isWaterfallCardAppLinked(card) |
| | | applyWaterfallCardVisualState( |
| | | card, |
| | | selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered), |
| | | selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused, |
| | | hovered: isHovered, |
| | | appLinked: isAppLinked |
| | | ) |
| | |
| | | ? CGSize(width: 0, height: -5) |
| | | : .zero |
| | | card.containerLayer.zPosition = selected ? 20 : hovered ? 12 : appLinked ? 10 : isSpaceFocused ? 8 : 0 |
| | | let focusedScale: CGFloat = selected ? 1.015 : isSpaceFocused ? 1.012 : 1.008 |
| | | let focusedScale: CGFloat = selected ? WaterfallMetrics.selectedCardScale : isSpaceFocused ? 1.012 : 1.008 |
| | | card.containerLayer.transform = (selected || hovered || isSpaceFocused) |
| | | ? CATransform3DMakeScale(focusedScale, focusedScale, 1) |
| | | : CATransform3DIdentity |
| | |
| | | x: locationInWaterfallContent.x - column.containerLayer.frame.minX, |
| | | y: locationInWaterfallContent.y - column.containerLayer.frame.minY |
| | | ) |
| | | for card in column.cards where card.containerLayer.frame.contains(locationInColumn) { |
| | | guard column.cardsClipLayer.frame.contains(locationInColumn) else { |
| | | continue |
| | | } |
| | | let locationInCardsClip = CGPoint( |
| | | x: locationInColumn.x - column.cardsClipLayer.frame.minX, |
| | | y: locationInColumn.y - column.cardsClipLayer.frame.minY |
| | | ) |
| | | for card in column.cards where card.containerLayer.frame.contains(locationInCardsClip) { |
| | | guard card.closeButton.containerLayer.opacity > 0 else { continue } |
| | | let locationInCard = CGPoint( |
| | | x: locationInColumn.x - card.containerLayer.frame.minX, |
| | | y: locationInColumn.y - card.containerLayer.frame.minY |
| | | x: locationInCardsClip.x - card.containerLayer.frame.minX, |
| | | y: locationInCardsClip.y - card.containerLayer.frame.minY |
| | | ) |
| | | if card.closeButton.containerLayer.frame.insetBy(dx: -8, dy: -8).contains(locationInCard) { |
| | | return .window( |
| | |
| | | x: waterfallLayer.frame.minX |
| | | + waterfallContentLayer.frame.minX |
| | | + column.containerLayer.frame.minX |
| | | + column.cardsClipLayer.frame.minX |
| | | + card.containerLayer.frame.minX |
| | | + card.closeButton.containerLayer.frame.midX, |
| | | y: waterfallLayer.frame.minY |
| | | + waterfallContentLayer.frame.minY |
| | | + column.containerLayer.frame.minY |
| | | + column.cardsClipLayer.frame.minY |
| | | + card.containerLayer.frame.minY |
| | | + card.closeButton.containerLayer.frame.midY |
| | | ) |
| | |
| | | ) |
| | | let contentHeight = CGFloat(column.cards.count) * WaterfallMetrics.cardHeight |
| | | + CGFloat(max(0, column.cards.count - 1)) * WaterfallMetrics.cardGap |
| | | return max(0, contentHeight - visibleHeight) |
| | | return max(0, contentHeight - visibleHeight + WaterfallMetrics.selectedCardScaleOverflow * 2) |
| | | } |
| | | |
| | | private func clampedWaterfallColumnOffset(_ offset: CGFloat, appGroupIndex: Int) -> CGFloat { |
| | |
| | | let appGroupIndex = column.column.appGroupIndex |
| | | let isHovered = appGroupIndex == effectiveHoveredAppGroupIndex |
| | | let firstCardFrame = column.cards.first?.containerLayer.frame |
| | | let headerToFirstCardGap = firstCardFrame.map { |
| | | let visibleFirstCardFrame = firstCardFrame.map { |
| | | $0.offsetBy(dx: column.cardsClipLayer.frame.minX, dy: column.cardsClipLayer.frame.minY) |
| | | } |
| | | let headerToFirstCardGap = visibleFirstCardFrame.map { |
| | | column.headerLayer.frame.minY - $0.maxY |
| | | } |
| | | let topToFirstCardGap = firstCardFrame.map { |
| | | let topToFirstCardGap = visibleFirstCardFrame.map { |
| | | column.containerLayer.bounds.height - $0.maxY |
| | | } |
| | | |
| | |
| | | "visibleFrame": dictionary(from: visibleFrame), |
| | | "headerFrame": dictionary(from: column.headerLayer.frame), |
| | | "headerBackgroundColor": colorDictionary(from: column.headerLayer.backgroundColor), |
| | | "cardsClipFrame": dictionary(from: column.cardsClipLayer.frame), |
| | | "cardsClipMasksToBounds": column.cardsClipLayer.masksToBounds, |
| | | "headerToFirstCardGap": headerToFirstCardGap.map(Double.init) ?? NSNull(), |
| | | "topToFirstCardGap": topToFirstCardGap.map(Double.init) ?? NSNull(), |
| | | "appNameFrame": dictionary(from: column.appNameLayer.frame), |
| | |
| | | private func waterfallCardReports(for column: WaterfallColumnLayers) -> [[String: Any]] { |
| | | column.cards.map { card in |
| | | let visibleFrame = card.containerLayer.frame.offsetBy( |
| | | dx: column.containerLayer.frame.minX + waterfallContentLayer.frame.minX, |
| | | dy: column.containerLayer.frame.minY |
| | | dx: column.containerLayer.frame.minX + column.cardsClipLayer.frame.minX + waterfallContentLayer.frame.minX, |
| | | dy: column.containerLayer.frame.minY + column.cardsClipLayer.frame.minY |
| | | ) |
| | | let screenshotSource = screenshotSourcesByWindowID[card.item.window.id] |
| | | |
| | |
| | | "isFullscreen": card.item.window.isFullscreen, |
| | | "identifierSource": "\(card.item.window.identifierSource)", |
| | | "isSelected": card.item.window.id == effectiveSelection?.windowID, |
| | | "isKeyboardFocused": card.item.window.id == keyboardFocusedWindowID, |
| | | "visualStates": waterfallCardVisualStates(for: card), |
| | | "thumbnailStrategy": thumbnailStrategyString(for: card.item.window), |
| | | "screenshotSource": screenshotSourceString(screenshotSource, for: card.item.window), |
| | |
| | | var states: [String] = [] |
| | | let isSelected = card.item.window.id == effectiveSelection?.windowID |
| | | let isHovered = card.item.window.id == hoveredWindowID |
| | | let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID |
| | | let isAppLinked = isWaterfallCardAppLinked(card) |
| | | if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) { |
| | | if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused { |
| | | states.append("selected") |
| | | } else if isSelected { |
| | | states.append("selectedVisualSuppressed") |
| | | } |
| | | if isKeyboardFocused { |
| | | states.append("keyboardFocused") |
| | | } |
| | | if isHovered { |
| | | states.append("hover") |
| | | } |