| | |
| | | let labelLayer: CATextLayer |
| | | let countLayer: CATextLayer |
| | | let appLayer: CATextLayer |
| | | let splitAppLayers: [CATextLayer] |
| | | let windowBlocks: [CALayer] |
| | | let fullscreenMarkerLayer: CALayer? |
| | | } |
| | |
| | | } |
| | | recordMouseCommand("click-card:\(appGroupIndex):\(windowIndex)") |
| | | if let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) { |
| | | clickWindowCard(card) |
| | | } |
| | | case "click-card-id" where parts.count == 2: |
| | | guard let windowID = UInt32(parts[1]) else { |
| | | recordMouseCommand("invalid:\(command)") |
| | | return |
| | | } |
| | | recordMouseCommand("click-card-id:\(windowID)") |
| | | if let card = waterfallCard(windowID: windowID) { |
| | | clickWindowCard(card) |
| | | } |
| | | case "hover-card-close" where parts.count == 3: |
| | |
| | | color: space.type == .fullscreen ? .labelColor : .secondaryLabelColor, |
| | | alignment: space.type == .fullscreen ? .center : .left |
| | | ) |
| | | let splitAppLayers = splitViewAppNames(for: space).map { name in |
| | | makeTextLayer( |
| | | string: name, |
| | | fontSize: 12, |
| | | weight: .medium, |
| | | color: .labelColor, |
| | | alignment: .center |
| | | ) |
| | | } |
| | | let windowBlocks = space.type == .fullscreen |
| | | ? [] |
| | | : makeWindowBlocks(count: min(space.windowCount, 5), space: space) |
| | |
| | | container.addSublayer(appLayer) |
| | | if let fullscreenMarkerLayer { |
| | | container.addSublayer(fullscreenMarkerLayer) |
| | | } |
| | | for splitAppLayer in splitAppLayers { |
| | | container.addSublayer(splitAppLayer) |
| | | } |
| | | for block in windowBlocks { |
| | | container.addSublayer(block) |
| | |
| | | labelLayer: labelLayer, |
| | | countLayer: countLayer, |
| | | appLayer: appLayer, |
| | | splitAppLayers: splitAppLayers, |
| | | windowBlocks: windowBlocks, |
| | | fullscreenMarkerLayer: fullscreenMarkerLayer |
| | | ) |
| | |
| | | let isActive = isHovered || isFocused || isLocked |
| | | let isAssociated = (isAppAssociated || isWindowAssociated) && !isActive |
| | | |
| | | segment.containerLayer.borderWidth = isLocked |
| | | ? 6 |
| | | : (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1 |
| | | segment.containerLayer.borderColor = (isActive |
| | | segment.containerLayer.borderWidth = (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1 |
| | | segment.containerLayer.borderColor = (isLocked |
| | | ? NSColor.white.withAlphaComponent(0.86) |
| | | : isActive |
| | | ? spaceLaneOccupiedColor.withAlphaComponent(isLocked ? 0.92 : 0.62) |
| | | : isAssociated |
| | | ? spaceLaneOccupiedColor.withAlphaComponent(0.50) |
| | |
| | | ? NSColor.labelColor.withAlphaComponent(0.34) |
| | | : NSColor.separatorColor.withAlphaComponent(0.34) |
| | | ).cgColor |
| | | if !hasWindows { |
| | | if isLocked { |
| | | applySpaceLaneSolidFill( |
| | | to: segment.containerLayer, |
| | | color: spaceLaneLockedFillColor |
| | | ) |
| | | } else if !hasWindows { |
| | | applySpaceLaneSolidFill(to: segment.containerLayer, color: .clear) |
| | | } else if isActive { |
| | | applySpaceLaneSolidFill( |
| | |
| | | segment.containerLayer.transform = (isActive || isAssociated) |
| | | ? CATransform3DMakeScale(isActive ? 1.03 : 1.015, isActive ? 1.03 : 1.015, 1) |
| | | : CATransform3DIdentity |
| | | segment.containerLayer.shadowPath = hasWindows |
| | | segment.containerLayer.shadowPath = (hasWindows || isLocked) |
| | | ? CGPath( |
| | | roundedRect: segment.containerLayer.bounds, |
| | | cornerWidth: segment.containerLayer.cornerRadius, |
| | |
| | | transform: nil |
| | | ) |
| | | : nil |
| | | |
| | | applySpaceLaneContentVisualState(segment, isLocked: isLocked) |
| | | } |
| | | |
| | | private func applySpaceLaneContentVisualState(_ segment: SpaceLaneSegmentLayers, isLocked: Bool) { |
| | | let primaryTextColor: NSColor = isLocked ? .white : .labelColor |
| | | let secondaryTextColor: NSColor = isLocked |
| | | ? .white |
| | | : .secondaryLabelColor |
| | | |
| | | segment.labelLayer.foregroundColor = primaryTextColor.cgColor |
| | | segment.countLayer.foregroundColor = (isLocked ? NSColor.white : .secondaryLabelColor).cgColor |
| | | segment.appLayer.foregroundColor = (segment.space.type == .fullscreen |
| | | ? primaryTextColor |
| | | : secondaryTextColor |
| | | ).cgColor |
| | | for splitAppLayer in segment.splitAppLayers { |
| | | splitAppLayer.foregroundColor = primaryTextColor.cgColor |
| | | } |
| | | |
| | | for block in segment.windowBlocks { |
| | | block.backgroundColor = (isLocked |
| | | ? NSColor.white |
| | | : spaceLaneOccupiedColor.withAlphaComponent(segment.space.isCurrent ? 0.72 : 0.58) |
| | | ).cgColor |
| | | } |
| | | |
| | | (segment.fullscreenMarkerLayer as? CAShapeLayer)?.strokeColor = (isLocked |
| | | ? NSColor.white |
| | | : NSColor.secondaryLabelColor.withAlphaComponent(segment.space.isCurrent ? 0.42 : 0.28) |
| | | ).cgColor |
| | | } |
| | | |
| | | private func shouldScrollSpaceLane(for event: NSEvent) -> Bool { |
| | |
| | | .first(where: { $0.item.windowIndex == windowIndex }) |
| | | } |
| | | |
| | | private func waterfallCard(windowID: UInt32) -> WaterfallCardLayers? { |
| | | waterfallColumns |
| | | .lazy |
| | | .flatMap(\.cards) |
| | | .first(where: { $0.item.window.id == windowID }) |
| | | } |
| | | |
| | | private func layoutSpaceLaneSegmentContents(_ segment: SpaceLaneSegmentLayers) { |
| | | let bounds = segment.containerLayer.bounds |
| | | let splitAppNames = splitViewAppNames(for: segment.space) |
| | | let isSplitView = splitAppNames.count >= 2 |
| | | segment.labelLayer.frame = CGRect(x: 10, y: bounds.height - 29, width: max(1, bounds.width - 48), height: 20) |
| | | segment.countLayer.frame = CGRect(x: bounds.width - 34, y: bounds.height - 29, width: 24, height: 20) |
| | | segment.countLayer.string = isSplitView ? "" : "\(segment.space.windowCount)" |
| | | segment.countLayer.isHidden = isSplitView |
| | | if segment.space.type == .fullscreen { |
| | | let markerFrame = fullscreenMarkerFrame(in: bounds) |
| | | segment.appLayer.frame = CGRect( |
| | | x: 8, |
| | | y: markerFrame.midY - 8.5, |
| | | width: max(1, bounds.width - 16), |
| | | height: 17 |
| | | ) |
| | | segment.appLayer.isHidden = isSplitView |
| | | if isSplitView { |
| | | let labelY = markerFrame.midY - 8.5 |
| | | let halfWidth = markerFrame.width / 2 |
| | | for (index, splitAppLayer) in segment.splitAppLayers.enumerated() { |
| | | let x = markerFrame.minX + CGFloat(index) * halfWidth |
| | | splitAppLayer.isHidden = false |
| | | splitAppLayer.frame = CGRect( |
| | | x: x + 4, |
| | | y: labelY, |
| | | width: max(1, halfWidth - 8), |
| | | height: 17 |
| | | ) |
| | | } |
| | | } else { |
| | | segment.appLayer.frame = CGRect( |
| | | x: 8, |
| | | y: markerFrame.midY - 8.5, |
| | | width: max(1, bounds.width - 16), |
| | | height: 17 |
| | | ) |
| | | for splitAppLayer in segment.splitAppLayers { |
| | | splitAppLayer.isHidden = true |
| | | } |
| | | } |
| | | } else { |
| | | segment.appLayer.isHidden = false |
| | | segment.appLayer.frame = CGRect(x: 10, y: 9, width: max(1, bounds.width - 20), height: 17) |
| | | for splitAppLayer in segment.splitAppLayers { |
| | | splitAppLayer.isHidden = true |
| | | } |
| | | } |
| | | if let fullscreenMarkerLayer = segment.fullscreenMarkerLayer { |
| | | layoutFullscreenMarkerLayer(fullscreenMarkerLayer, in: bounds) |
| | | layoutFullscreenMarkerLayer( |
| | | fullscreenMarkerLayer, |
| | | in: bounds, |
| | | isSplitView: isSplitView |
| | | ) |
| | | } |
| | | |
| | | let blockGap: CGFloat = 5 |
| | |
| | | .cards |
| | | .compactMap(\.item.primarySpaceID) ?? [] |
| | | ) |
| | | } |
| | | |
| | | private var appIconHoverLinkedAppGroupIndex: Int? { |
| | | if let debugHoveredAppGroupIndex { |
| | | return debugHoveredAppGroupIndex |
| | | } |
| | | guard case .app(let appGroupIndex) = hoverTarget else { return nil } |
| | | return appGroupIndex |
| | | } |
| | | |
| | | private var appHoverAssociatedSpaceLabels: [String] { |
| | |
| | | let bounds = card.containerLayer.bounds |
| | | let isSelected = card.item.window.id == effectiveSelection?.windowID |
| | | let isHovered = card.item.window.id == hoveredWindowID |
| | | let isAppLinked = isHorizontalMasonryMode |
| | | && card.item.appGroupIndex == appIconHoverLinkedAppGroupIndex |
| | | applyWaterfallCardVisualState( |
| | | card, |
| | | selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered), |
| | | hovered: isHovered |
| | | hovered: isHovered, |
| | | appLinked: isAppLinked |
| | | ) |
| | | |
| | | let outerInset: CGFloat = 0 |
| | |
| | | ) |
| | | } |
| | | |
| | | private func applyWaterfallCardVisualState(_ card: WaterfallCardLayers, selected: Bool, hovered: Bool) { |
| | | private func applyWaterfallCardVisualState( |
| | | _ card: WaterfallCardLayers, |
| | | selected: Bool, |
| | | hovered: Bool, |
| | | appLinked: Bool |
| | | ) { |
| | | let isSpaceFocused = isWaterfallCardSpaceFocused(card) |
| | | card.containerLayer.backgroundColor = (selected |
| | | ? NSColor.windowBackgroundColor.withAlphaComponent(0.94) |
| | | : hovered |
| | | ? accentTintedWindowBackground(fraction: 0.09, alpha: 0.95) |
| | | : appLinked |
| | | ? accentTintedWindowBackground(fraction: 0.08, alpha: 0.93) |
| | | : isSpaceFocused |
| | | ? accentTintedWindowBackground(fraction: 0.10, alpha: 0.94) |
| | | : NSColor.windowBackgroundColor.withAlphaComponent(0.82) |
| | |
| | | ? NSColor.controlAccentColor.withAlphaComponent(0.52) |
| | | : hovered |
| | | ? NSColor.controlAccentColor.withAlphaComponent(0.44) |
| | | : appLinked |
| | | ? NSColor.controlAccentColor.withAlphaComponent(0.38) |
| | | : isSpaceFocused |
| | | ? NSColor.controlAccentColor.withAlphaComponent(0.42) |
| | | : NSColor.separatorColor.withAlphaComponent(0.28) |
| | | ).cgColor |
| | | card.containerLayer.borderWidth = (selected || hovered || isSpaceFocused) ? 1.5 : 1 |
| | | card.containerLayer.borderWidth = (selected || hovered || appLinked || isSpaceFocused) ? 1.5 : 1 |
| | | card.containerLayer.shadowColor = NSColor.black.cgColor |
| | | card.containerLayer.shadowOpacity = selected ? 0.24 : hovered ? 0.14 : isSpaceFocused ? 0.20 : 0 |
| | | card.containerLayer.shadowRadius = selected ? 22 : hovered ? 17 : isSpaceFocused ? 24 : 0 |
| | | card.containerLayer.shadowOpacity = selected ? 0.24 : hovered ? 0.14 : appLinked ? 0.16 : isSpaceFocused ? 0.20 : 0 |
| | | card.containerLayer.shadowRadius = selected ? 22 : hovered ? 17 : appLinked ? 18 : isSpaceFocused ? 24 : 0 |
| | | card.containerLayer.shadowOffset = selected |
| | | ? CGSize(width: 0, height: -6) |
| | | : hovered |
| | | ? CGSize(width: 0, height: -2.5) |
| | | : appLinked |
| | | ? CGSize(width: 0, height: -3) |
| | | : isSpaceFocused |
| | | ? CGSize(width: 0, height: -5) |
| | | : .zero |
| | | card.containerLayer.zPosition = selected ? 20 : hovered ? 10 : isSpaceFocused ? 8 : 0 |
| | | card.containerLayer.zPosition = selected ? 20 : hovered ? 12 : appLinked ? 10 : isSpaceFocused ? 8 : 0 |
| | | let focusedScale: CGFloat = selected ? 1.015 : isSpaceFocused ? 1.012 : 1.008 |
| | | card.containerLayer.transform = (selected || hovered || isSpaceFocused) |
| | | ? CATransform3DMakeScale(focusedScale, focusedScale, 1) |
| | | : CATransform3DIdentity |
| | | card.containerLayer.shadowPath = (selected || hovered || isSpaceFocused) |
| | | card.containerLayer.shadowPath = (selected || hovered || appLinked || isSpaceFocused) |
| | | ? CGPath( |
| | | roundedRect: card.containerLayer.bounds, |
| | | cornerWidth: card.containerLayer.cornerRadius, |
| | |
| | | transform: nil |
| | | ) |
| | | : nil |
| | | card.titleBarLayer.backgroundColor = NSColor.clear.cgColor |
| | | card.titleBarLayer.backgroundColor = appLinked |
| | | ? spaceLaneOccupiedColor.withAlphaComponent(0.18).cgColor |
| | | : NSColor.clear.cgColor |
| | | card.titleBarLayer.borderColor = NSColor.clear.cgColor |
| | | card.titleBarLayer.borderWidth = 0 |
| | | card.shineLayer.opacity = selected ? 1 : 0 |
| | |
| | | return NSColor(calibratedRed: 0, green: 122 / 255, blue: 1, alpha: 1) |
| | | } |
| | | |
| | | private var spaceLaneLockedFillColor: NSColor { |
| | | if effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua { |
| | | return NSColor(calibratedRed: 58 / 255, green: 148 / 255, blue: 1, alpha: 0.86) |
| | | } |
| | | |
| | | return NSColor(calibratedRed: 90 / 255, green: 171 / 255, blue: 1, alpha: 0.82) |
| | | } |
| | | |
| | | private func spaceLaneIdleOccupiedGradientColors() -> [NSColor] { |
| | | if effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua { |
| | | let blue = NSColor(calibratedRed: 36 / 255, green: 54 / 255, blue: 72 / 255, alpha: 1) |
| | |
| | | return identifiers.joined(separator: " ") |
| | | } |
| | | |
| | | private func layoutFullscreenMarkerLayer(_ layer: CALayer, in segmentBounds: CGRect) { |
| | | private func splitViewAppNames(for space: QuickSwitchSpaceViewModel) -> [String] { |
| | | guard space.type == .fullscreen else { return [] } |
| | | |
| | | let names = space.splitViewAppNames |
| | | .prefix(2) |
| | | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } |
| | | .filter { !$0.isEmpty } |
| | | |
| | | guard names.count >= 2 else { return [] } |
| | | return names |
| | | } |
| | | |
| | | private func layoutFullscreenMarkerLayer( |
| | | _ layer: CALayer, |
| | | in segmentBounds: CGRect, |
| | | isSplitView: Bool = false |
| | | ) { |
| | | let markerFrame = fullscreenMarkerFrame(in: segmentBounds) |
| | | layer.frame = markerFrame |
| | | |
| | | guard let shapeLayer = layer as? CAShapeLayer else { return } |
| | | |
| | | let bounds = shapeLayer.bounds |
| | | if isSplitView { |
| | | let path = CGMutablePath() |
| | | path.move(to: CGPoint(x: bounds.midX, y: bounds.minY)) |
| | | path.addLine(to: CGPoint(x: bounds.midX, y: bounds.maxY)) |
| | | shapeLayer.path = path |
| | | shapeLayer.lineCap = .butt |
| | | return |
| | | } |
| | | |
| | | let cornerLength = min(12, max(7, min(bounds.width, bounds.height) * 0.28)) |
| | | let path = CGMutablePath() |
| | | |
| | |
| | | path.addLine(to: CGPoint(x: 0, y: bounds.maxY - cornerLength)) |
| | | |
| | | shapeLayer.path = path |
| | | shapeLayer.lineCap = .round |
| | | } |
| | | |
| | | private func fullscreenMarkerFrame(in segmentBounds: CGRect) -> CGRect { |
| | |
| | | "shadowRadius": Double(card.containerLayer.shadowRadius), |
| | | "thumbnailFrame": dictionary(from: card.thumbnailLayer.frame), |
| | | "titleBarFrame": dictionary(from: card.titleBarLayer.frame), |
| | | "titleBarBackgroundColor": colorDictionary(from: card.titleBarLayer.backgroundColor), |
| | | "titleBarBorderWidth": Double(card.titleBarLayer.borderWidth), |
| | | "titleFrame": dictionary(from: card.titleLayer.frame), |
| | | "metaFrame": dictionary(from: card.metaLayer.frame), |
| | |
| | | var states: [String] = [] |
| | | let isSelected = card.item.window.id == effectiveSelection?.windowID |
| | | let isHovered = card.item.window.id == hoveredWindowID |
| | | let isAppLinked = isHorizontalMasonryMode |
| | | && card.item.appGroupIndex == appIconHoverLinkedAppGroupIndex |
| | | if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) { |
| | | states.append("selected") |
| | | } else if isSelected { |
| | |
| | | } |
| | | if isHovered { |
| | | states.append("hover") |
| | | } |
| | | if isAppLinked { |
| | | states.append("appLinked") |
| | | } |
| | | if isWaterfallCardSpaceFocused(card) { |
| | | states.append("spaceFocused") |
| | |
| | | let fullscreenMarkerCenterRatio = fullscreenMarkerFrame.map { |
| | | Double($0.midY / max(1, segment.containerLayer.bounds.height)) |
| | | } |
| | | let splitAppNames = splitViewAppNames(for: space) |
| | | let splitAppLabelFrames = segment.splitAppLayers.map { dictionary(from: $0.frame) } |
| | | return [ |
| | | "displayUUID": display.displayUUID, |
| | | "displayLabel": display.displayLabel, |
| | |
| | | "type": spaceTypeName(space.type), |
| | | "visualStates": visualStates(for: space), |
| | | "fullscreenMarkerVisible": segment.fullscreenMarkerLayer != nil, |
| | | "fullscreenMarkerKind": segment.fullscreenMarkerLayer == nil ? "none" : "cornerBracket", |
| | | "fullscreenMarkerKind": fullscreenMarkerKind( |
| | | markerLayer: segment.fullscreenMarkerLayer, |
| | | splitAppNames: splitAppNames |
| | | ), |
| | | "fullscreenMarkerPathStyle": fullscreenMarkerPathStyle( |
| | | markerLayer: segment.fullscreenMarkerLayer, |
| | | splitAppNames: splitAppNames |
| | | ), |
| | | "fullscreenMarkerFrame": fullscreenMarkerFrame.map { dictionary(from: $0) } ?? NSNull(), |
| | | "fullscreenMarkerCenterRatio": fullscreenMarkerCenterRatio ?? NSNull(), |
| | | "splitViewAppNames": splitAppNames, |
| | | "splitViewLabelFrames": splitAppLabelFrames, |
| | | "splitViewLabelColors": segment.splitAppLayers.map { |
| | | colorDictionary(from: $0.foregroundColor) |
| | | }, |
| | | "windowBlockCount": segment.windowBlocks.count, |
| | | "windowBlockColors": segment.windowBlocks.map { colorDictionary(from: $0.backgroundColor) }, |
| | | "windowBlockFrames": segment.windowBlocks.map { dictionary(from: $0.frame) }, |
| | | "labelColor": colorDictionary(from: segment.labelLayer.foregroundColor), |
| | | "countText": string(from: segment.countLayer.string), |
| | | "countVisible": !segment.countLayer.isHidden && segment.countLayer.opacity > 0, |
| | | "countColor": colorDictionary(from: segment.countLayer.foregroundColor), |
| | | "appColor": colorDictionary(from: segment.appLayer.foregroundColor), |
| | | "fullscreenMarkerStrokeColor": colorDictionary( |
| | | from: (segment.fullscreenMarkerLayer as? CAShapeLayer)?.strokeColor |
| | | ), |
| | | "backgroundKind": spaceLaneFillKind(for: segment.containerLayer), |
| | | "backgroundColor": colorDictionary(from: segment.containerLayer.backgroundColor), |
| | | "gradientColors": gradientColorDictionaries(from: segment.containerLayer), |
| | |
| | | } |
| | | } |
| | | |
| | | private func fullscreenMarkerKind(markerLayer: CALayer?, splitAppNames: [String]) -> String { |
| | | guard markerLayer != nil else { return "none" } |
| | | return splitAppNames.count >= 2 ? "splitViewPair" : "cornerBracket" |
| | | } |
| | | |
| | | private func fullscreenMarkerPathStyle(markerLayer: CALayer?, splitAppNames: [String]) -> String { |
| | | guard markerLayer != nil else { return "none" } |
| | | return splitAppNames.count >= 2 ? "centerDividerOnly" : "cornerBracket" |
| | | } |
| | | |
| | | private func visualStates(for space: QuickSwitchSpaceViewModel) -> [String] { |
| | | var states: [String] = [] |
| | | if space.isCurrent { |
| | |
| | | } |
| | | if space.type == .fullscreen { |
| | | states.append("fullscreen") |
| | | } |
| | | if splitViewAppNames(for: space).count >= 2 { |
| | | states.append("splitView") |
| | | } |
| | | return states |
| | | } |
| | |
| | | ] |
| | | } |
| | | |
| | | private func string(from textLayerValue: Any?) -> String { |
| | | if let string = textLayerValue as? String { |
| | | return string |
| | | } |
| | | if let attributedString = textLayerValue as? NSAttributedString { |
| | | return attributedString.string |
| | | } |
| | | return "" |
| | | } |
| | | |
| | | private func colorDictionary(from color: CGColor?) -> [String: Double] { |
| | | guard let color, |
| | | let nsColor = NSColor(cgColor: color)?.usingColorSpace(.deviceRGB) |