| | |
| | | private lazy var closeConfirmationLayers = makeCloseConfirmationLayers() |
| | | private let closeFeedbackLayer = CALayer() |
| | | private let closeFeedbackTextLayer = CATextLayer() |
| | | private let settingsButtonLayer = CALayer() |
| | | private let settingsButtonIconLayer = CALayer() |
| | | private let settingsButtonFallbackTextLayer = CATextLayer() |
| | | private let appIconProvider: any AppIconProviderProtocol = WorkspaceAppIconProvider() |
| | | private let skeletonThumbnailProvider = NativeSkeletonThumbnailProvider(size: NSSize(width: 160, height: 96)) |
| | | private var screenshotSourcesByWindowID: [UInt32: ScreenshotResolutionSource] = [:] |
| | |
| | | private var closeConfirmationRequired = true |
| | | private var closeFeedback: CloseFeedback? |
| | | private var closeFeedbackHideTask: Task<Void, Never>? |
| | | private var isSettingsButtonHovered = false |
| | | private var hoverTarget: HoverTarget = .none |
| | | private var hoverTargetSource: HoverTargetSource = .event |
| | | private var hoveredSpaceLaneID: UInt64? |
| | |
| | | var onCommitSelection: ((QuickSwitchSelection, QuickSwitchCommitSource) -> Void)? |
| | | var onSpaceLaneClick: ((UInt64, Int) -> Void)? |
| | | var onBackgroundClick: (() -> Bool)? |
| | | var onOpenSettings: (() -> Void)? |
| | | var onRequestClose: ((QuickSwitchCloseRequest) -> Void)? |
| | | var onCloseConfirmationDisabled: (() -> Void)? |
| | | |
| | |
| | | let labelLayer: CATextLayer |
| | | let countLayer: CATextLayer |
| | | let appLayer: CATextLayer |
| | | let splitAppLayers: [CATextLayer] |
| | | let windowBlocks: [CALayer] |
| | | let fullscreenMarkerLayer: CALayer? |
| | | } |
| | |
| | | private enum HorizontalMasonryMetrics { |
| | | static let horizontalPadding: CGFloat = 12 |
| | | static let verticalPadding: CGFloat = 0 |
| | | static let cardGap: CGFloat = 16 |
| | | static let horizontalGap: CGFloat = 16 |
| | | static let verticalGap: CGFloat = WaterfallMetrics.cardGap |
| | | static let cardMinWidth: CGFloat = 220 |
| | | static let cardMaxWidth: CGFloat = 320 |
| | | static let titleBarHeight: CGFloat = 26 |
| | | static let titleGap: CGFloat = 6 |
| | | static let minThumbnailHeight: CGFloat = 92 |
| | | static let maxThumbnailHeightFraction: CGFloat = 0.40 |
| | | static let cardHeight: CGFloat = WaterfallMetrics.cardHeight |
| | | } |
| | | |
| | | private enum WaterfallHorizontalIntent { |
| | |
| | | override func mouseMoved(with event: NSEvent) { |
| | | guard debugHoveredAppGroupIndex == nil else { return } |
| | | |
| | | let settingsHovered = settingsButtonContains(windowLocation: event.locationInWindow) |
| | | if isSettingsButtonHovered != settingsHovered { |
| | | isSettingsButtonHovered = settingsHovered |
| | | needsLayout = true |
| | | } |
| | | if settingsHovered { |
| | | hoveredCloseTarget = nil |
| | | setHoverTarget(.none) |
| | | return |
| | | } |
| | | |
| | | let closeTarget = closeButtonTarget(at: event.locationInWindow) |
| | | if hoveredCloseTarget != closeTarget { |
| | | hoveredCloseTarget = closeTarget |
| | |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | | guard debugHoveredAppGroupIndex == nil else { return } |
| | | isSettingsButtonHovered = false |
| | | hoveredCloseTarget = nil |
| | | setHoverTarget(.none) |
| | | } |
| | |
| | | } |
| | | |
| | | if handleCloseConfirmationMouseDown(at: event.locationInWindow) { |
| | | return |
| | | } |
| | | |
| | | if settingsButtonContains(windowLocation: event.locationInWindow) { |
| | | clickSettingsButton(source: "mouse") |
| | | return |
| | | } |
| | | |
| | |
| | | "activeSpaceFocusID": activeSpaceFocusID ?? NSNull(), |
| | | "spaceFilterLockedSpaceID": currentViewModel?.lockedSpaceID ?? NSNull(), |
| | | "spaceFilterActive": currentViewModel?.lockedSpaceID != nil, |
| | | "settingsButtonVisible": !settingsButtonLayer.isHidden, |
| | | "settingsButtonHovered": isSettingsButtonHovered, |
| | | "settingsButtonFrame": dictionary(from: settingsButtonLayer.frame), |
| | | "spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(), |
| | | "appHoverAssociatedSpaceIDs": appHoverAssociatedSpaceIDs.sorted(), |
| | | "appHoverAssociatedSpaceLabels": appHoverAssociatedSpaceLabels, |
| | |
| | | case "click-background": |
| | | recordMouseCommand("click-background") |
| | | _ = onBackgroundClick?() |
| | | case "click-settings": |
| | | recordMouseCommand("click-settings") |
| | | clickSettingsButton(source: "debug") |
| | | case "hover-card" where parts.count == 3: |
| | | guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else { |
| | | recordMouseCommand("invalid:\(command)") |
| | |
| | | } |
| | | 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: |
| | |
| | | closeFeedbackTextLayer.truncationMode = .end |
| | | closeFeedbackLayer.addSublayer(closeFeedbackTextLayer) |
| | | glassLayer.addSublayer(closeFeedbackLayer) |
| | | |
| | | settingsButtonLayer.cornerRadius = 10 |
| | | settingsButtonLayer.masksToBounds = false |
| | | settingsButtonLayer.borderWidth = 1 |
| | | settingsButtonLayer.shadowColor = NSColor.black.cgColor |
| | | settingsButtonLayer.shadowOffset = CGSize(width: 0, height: -2) |
| | | settingsButtonLayer.zPosition = 170 |
| | | settingsButtonIconLayer.contentsGravity = .resizeAspect |
| | | settingsButtonIconLayer.contentsScale = backingScaleFactor |
| | | settingsButtonIconLayer.contents = settingsIconImage() |
| | | settingsButtonFallbackTextLayer.contentsScale = backingScaleFactor |
| | | settingsButtonFallbackTextLayer.string = "⚙" |
| | | settingsButtonFallbackTextLayer.font = NSFont.systemFont(ofSize: 18, weight: .regular) |
| | | settingsButtonFallbackTextLayer.fontSize = 18 |
| | | settingsButtonFallbackTextLayer.alignmentMode = .center |
| | | settingsButtonFallbackTextLayer.foregroundColor = NSColor.secondaryLabelColor.cgColor |
| | | settingsButtonFallbackTextLayer.isHidden = settingsButtonIconLayer.contents != nil |
| | | settingsButtonLayer.addSublayer(settingsButtonIconLayer) |
| | | settingsButtonLayer.addSublayer(settingsButtonFallbackTextLayer) |
| | | glassLayer.addSublayer(settingsButtonLayer) |
| | | } |
| | | |
| | | private func rebuildAppShelfItems() { |
| | |
| | | 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 |
| | | ) |
| | |
| | | layoutWaterfallColumns() |
| | | layoutCloseConfirmation() |
| | | layoutCloseFeedback() |
| | | layoutSettingsButton() |
| | | |
| | | CATransaction.commit() |
| | | } |
| | | |
| | | private func layoutSettingsButton() { |
| | | let size: CGFloat = 34 |
| | | let topMargin = topSafeAreaInset + RootLayoutMetrics.topContentMargin |
| | | let x = max( |
| | | RootLayoutMetrics.horizontalMargin, |
| | | glassLayer.bounds.width - RootLayoutMetrics.horizontalMargin - size |
| | | ) |
| | | let y = max( |
| | | RootLayoutMetrics.bottomMargin, |
| | | glassLayer.bounds.height - topMargin - size |
| | | ) |
| | | settingsButtonLayer.frame = CGRect(x: x, y: y, width: size, height: size) |
| | | settingsButtonLayer.backgroundColor = NSColor.windowBackgroundColor |
| | | .withAlphaComponent(isSettingsButtonHovered ? 0.78 : 0.54) |
| | | .cgColor |
| | | settingsButtonLayer.borderColor = NSColor.separatorColor |
| | | .withAlphaComponent(isSettingsButtonHovered ? 0.42 : 0.26) |
| | | .cgColor |
| | | settingsButtonLayer.shadowOpacity = isSettingsButtonHovered ? 0.14 : 0.08 |
| | | settingsButtonLayer.shadowRadius = isSettingsButtonHovered ? 9 : 6 |
| | | settingsButtonIconLayer.contentsScale = backingScaleFactor |
| | | settingsButtonFallbackTextLayer.contentsScale = backingScaleFactor |
| | | let iconInset: CGFloat = 8 |
| | | settingsButtonIconLayer.frame = settingsButtonLayer.bounds.insetBy(dx: iconInset, dy: iconInset) |
| | | settingsButtonFallbackTextLayer.frame = settingsButtonLayer.bounds.insetBy(dx: 0, dy: 6) |
| | | settingsButtonFallbackTextLayer.foregroundColor = (isSettingsButtonHovered |
| | | ? NSColor.labelColor |
| | | : NSColor.secondaryLabelColor |
| | | ).cgColor |
| | | } |
| | | |
| | | private var topSafeAreaInset: CGFloat { |
| | |
| | | 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 { |
| | |
| | | spaceLaneSegments.first { $0.space.id == spaceID } |
| | | } |
| | | |
| | | private func settingsButtonContains(windowLocation: CGPoint) -> Bool { |
| | | let locationInView = convert(windowLocation, from: nil) |
| | | let locationInGlass = CGPoint( |
| | | x: locationInView.x - glassLayer.frame.minX, |
| | | y: locationInView.y - glassLayer.frame.minY |
| | | ) |
| | | return settingsButtonLayer.frame.contains(locationInGlass) |
| | | } |
| | | |
| | | private func clickSettingsButton(source: String) { |
| | | DevelopmentDiagnostics.log("quickSwitch.view.clickSettings", [ |
| | | "source": source |
| | | ]) |
| | | lastMouseCommand = "click-settings" |
| | | if !mouseCommandsApplied.contains("click-settings") { |
| | | mouseCommandsApplied.append("click-settings") |
| | | } |
| | | onOpenSettings?() |
| | | } |
| | | |
| | | private func appShelfItem(at windowLocation: CGPoint) -> AppShelfItemLayers? { |
| | | let locationInView = convert(windowLocation, from: nil) |
| | | let locationInShelfContent = CGPoint( |
| | |
| | | .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 |
| | |
| | | 1, |
| | | Int( |
| | | floor( |
| | | (availableWidth + HorizontalMasonryMetrics.cardGap) |
| | | / (HorizontalMasonryMetrics.cardMinWidth + HorizontalMasonryMetrics.cardGap) |
| | | (availableWidth + HorizontalMasonryMetrics.horizontalGap) |
| | | / (HorizontalMasonryMetrics.cardMinWidth + HorizontalMasonryMetrics.horizontalGap) |
| | | ) |
| | | ) |
| | | ) |
| | | let laneCount = min(maximumLaneCount, max(1, cards.count)) |
| | | let fittingWidth = ( |
| | | availableWidth - CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.cardGap |
| | | availableWidth - CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.horizontalGap |
| | | ) / CGFloat(laneCount) |
| | | let cardWidth = floor( |
| | | min( |
| | |
| | | ) |
| | | ) |
| | | let usedWidth = CGFloat(laneCount) * cardWidth |
| | | + CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.cardGap |
| | | + CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.horizontalGap |
| | | let startX = max( |
| | | HorizontalMasonryMetrics.horizontalPadding, |
| | | (viewportWidth - usedWidth) / 2 |
| | |
| | | } |
| | | return lhs.offset < rhs.offset |
| | | }?.offset ?? 0 |
| | | let cardHeight = horizontalMasonryCardHeight( |
| | | for: card, |
| | | width: cardWidth, |
| | | viewportHeight: waterfallLayer.bounds.height |
| | | ) |
| | | let x = startX + CGFloat(laneIndex) * (cardWidth + HorizontalMasonryMetrics.cardGap) |
| | | let cardHeight = HorizontalMasonryMetrics.cardHeight |
| | | let x = startX + CGFloat(laneIndex) * (cardWidth + HorizontalMasonryMetrics.horizontalGap) |
| | | let top = HorizontalMasonryMetrics.verticalPadding + laneHeights[laneIndex] |
| | | plannedFrames.append(( |
| | | windowID: card.item.window.id, |
| | |
| | | width: cardWidth, |
| | | height: cardHeight |
| | | )) |
| | | laneHeights[laneIndex] += cardHeight + HorizontalMasonryMetrics.cardGap |
| | | laneHeights[laneIndex] += cardHeight + HorizontalMasonryMetrics.verticalGap |
| | | } |
| | | |
| | | let tallestLane = laneHeights.max().map { |
| | | max(0, $0 - HorizontalMasonryMetrics.cardGap) |
| | | max(0, $0 - HorizontalMasonryMetrics.verticalGap) |
| | | } ?? 0 |
| | | let contentHeight = max( |
| | | waterfallLayer.bounds.height, |
| | |
| | | ) |
| | | |
| | | return HorizontalMasonryLayout(contentHeight: contentHeight, cardFramesByWindowID: frames) |
| | | } |
| | | |
| | | private func horizontalMasonryCardHeight( |
| | | for card: WaterfallCardLayers, |
| | | width: CGFloat, |
| | | viewportHeight: CGFloat |
| | | ) -> CGFloat { |
| | | let thumbnailHeight = min( |
| | | max( |
| | | HorizontalMasonryMetrics.minThumbnailHeight, |
| | | width / horizontalMasonryThumbnailAspectRatio(for: card) |
| | | ), |
| | | max(1, viewportHeight * HorizontalMasonryMetrics.maxThumbnailHeightFraction) |
| | | ) |
| | | return HorizontalMasonryMetrics.titleBarHeight |
| | | + HorizontalMasonryMetrics.titleGap |
| | | + thumbnailHeight |
| | | } |
| | | |
| | | private func horizontalMasonryThumbnailAspectRatio(for card: WaterfallCardLayers) -> CGFloat { |
| | | if card.item.window.isFullscreen { |
| | | return 16.0 / 9.0 |
| | | } |
| | | if card.item.window.isMinimized { |
| | | return 4.0 / 3.0 |
| | | } |
| | | |
| | | switch Int(card.item.window.id % 4) { |
| | | case 0: |
| | | return 16.0 / 9.0 |
| | | case 1: |
| | | return 3.0 / 2.0 |
| | | case 2: |
| | | return 4.0 / 3.0 |
| | | default: |
| | | return 5.0 / 4.0 |
| | | } |
| | | } |
| | | |
| | | private func horizontalMasonryContentFrame() -> CGRect { |
| | |
| | | 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) |
| | |
| | | shapeLayer.lineCap = .round |
| | | shapeLayer.lineJoin = .round |
| | | return shapeLayer |
| | | } |
| | | |
| | | private func settingsIconImage() -> CGImage? { |
| | | let names = [ |
| | | NSImage.Name("NSPreferencesGeneral"), |
| | | NSImage.Name("NSAdvanced") |
| | | ] |
| | | for name in names { |
| | | guard let image = NSImage(named: name) else { continue } |
| | | var rect = CGRect(origin: .zero, size: image.size) |
| | | if let cgImage = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) { |
| | | return cgImage |
| | | } |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | private func makeTextLayer( |
| | |
| | | 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 { |
| | |
| | | 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) |