| | |
| | | |
| | | @MainActor |
| | | final class QuickSwitchRootView: NSView { |
| | | private let backdropBlurView = NSVisualEffectView() |
| | | private let layerHostView = NSView() |
| | | private let backgroundLayer = CAGradientLayer() |
| | | private let safeAreaFillLayer = CALayer() |
| | | private let glassLayer = CALayer() |
| | | private let laneLayer = CALayer() |
| | | private let spaceLaneContentLayer = CALayer() |
| | |
| | | static let maxRows = 2 |
| | | } |
| | | |
| | | private enum RootLayoutMetrics { |
| | | static let horizontalMargin: CGFloat = 24 |
| | | static let topContentMargin: CGFloat = 24 |
| | | static let shelfGap: CGFloat = 16 |
| | | static let waterfallTopGap: CGFloat = 14 |
| | | static let bottomMargin: CGFloat = 34 |
| | | } |
| | | |
| | | private enum WaterfallMetrics { |
| | | static let gap: CGFloat = 16 |
| | | static let columnWidth: CGFloat = 252 |
| | |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setupBackdropBlurView() |
| | | setupLayerHostView() |
| | | wantsLayer = true |
| | | setupLayers() |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setupBackdropBlurView() |
| | | setupLayerHostView() |
| | | wantsLayer = true |
| | | setupLayers() |
| | | } |
| | |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | backdropBlurView.frame = bounds |
| | | layerHostView.frame = bounds |
| | | layoutLayers() |
| | | } |
| | | |
| | |
| | | if isHoverCommand(command) { |
| | | hoverResponseLatencyMilliseconds.append(elapsed) |
| | | } |
| | | if lastCommitSource == .mouse { |
| | | break |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | "viewClass": String(describing: Self.self), |
| | | "isLayerBacked": wantsLayer, |
| | | "bounds": dictionary(from: bounds), |
| | | "safeAreaTopInset": Double(topSafeAreaInset), |
| | | "safeAreaFillFrame": dictionary(from: safeAreaFillLayer.frame), |
| | | "glassFrame": dictionary(from: glassLayer.frame), |
| | | "glassCornerRadius": Double(glassLayer.cornerRadius), |
| | | "glassBorderWidth": Double(glassLayer.borderWidth), |
| | | "glassShadowOpacity": Double(glassLayer.shadowOpacity), |
| | | "spaceLaneFrame": dictionary(from: laneLayer.frame), |
| | | "spaceLaneContentWidth": Double(spaceLaneContentWidth), |
| | | "spaceLaneVisibleWidth": Double(laneLayer.bounds.width), |
| | |
| | | "spaceID": segment.space.id, |
| | | "label": segment.space.label |
| | | ]) |
| | | focusSpace(segment.space.id, persistent: true) |
| | | lastSpaceLaneClickSpaceID = segment.space.id |
| | | |
| | | guard let firstCard = firstWaterfallCard(inSpaceID: segment.space.id) else { |
| | | DevelopmentDiagnostics.log("quickSwitch.view.clickSpaceLane.emptySpaceUnsupported", [ |
| | | "spaceID": segment.space.id, |
| | | "label": segment.space.label |
| | | ]) |
| | | return |
| | | } |
| | | |
| | | commitWindowCard(firstCard, trigger: "spaceLane") |
| | | } |
| | | |
| | | private func focusSpace(_ spaceID: UInt64, persistent: Bool) { |
| | |
| | | columnSelectionHistory[nextSelection.appGroupIndex] = nextSelection.windowIndex |
| | | lastAppShelfClickChangedSelection = previousSelection != nextSelection |
| | | ensureWaterfallSelectionVisible(nextSelection, horizontalIntent: .alignWithAppShelf(animated: true)) |
| | | updateHoverState(appGroupIndex: appGroupIndex, windowID: nil, spaceID: nil) |
| | | commitWindowCard(firstCard, trigger: "appShelf") |
| | | } else { |
| | | lastAppShelfClickChangedSelection = false |
| | | ensureWaterfallColumnVisible(column) |
| | | updateHoverState(appGroupIndex: appGroupIndex, windowID: nil, spaceID: nil) |
| | | needsLayout = true |
| | | } |
| | | updateHoverState(appGroupIndex: appGroupIndex, windowID: nil, spaceID: nil) |
| | | needsLayout = true |
| | | } |
| | | |
| | | private func scrollAppShelf(to targetOffset: CGFloat) { |
| | |
| | | |
| | | private func clickWindowCard(_ card: WaterfallCardLayers) { |
| | | hoverWindowCard(card) |
| | | commitWindowCard(card, trigger: "windowCard") |
| | | } |
| | | |
| | | private func commitWindowCard(_ card: WaterfallCardLayers, trigger: String) { |
| | | let selection = QuickSwitchSelection( |
| | | appGroupIndex: card.item.appGroupIndex, |
| | | windowIndex: card.item.windowIndex, |
| | | windowID: card.item.window.id |
| | | ) |
| | | DevelopmentDiagnostics.log("quickSwitch.view.clickWindowCard", [ |
| | | DevelopmentDiagnostics.log("quickSwitch.view.commitMouseWindow", [ |
| | | "trigger": trigger, |
| | | "appGroupIndex": selection.appGroupIndex, |
| | | "windowIndex": selection.windowIndex, |
| | | "windowID": selection.windowID, |
| | |
| | | value ?? NSNull() |
| | | } |
| | | |
| | | private func setupBackdropBlurView() { |
| | | backdropBlurView.material = .fullScreenUI |
| | | backdropBlurView.blendingMode = .behindWindow |
| | | backdropBlurView.state = .active |
| | | backdropBlurView.isEmphasized = false |
| | | backdropBlurView.frame = bounds |
| | | backdropBlurView.autoresizingMask = [.width, .height] |
| | | addSubview(backdropBlurView, positioned: .below, relativeTo: nil) |
| | | } |
| | | |
| | | private func setupLayerHostView() { |
| | | layerHostView.wantsLayer = true |
| | | layerHostView.frame = bounds |
| | | layerHostView.autoresizingMask = [.width, .height] |
| | | layerHostView.layer?.masksToBounds = true |
| | | layerHostView.layer?.backgroundColor = NSColor.clear.cgColor |
| | | addSubview(layerHostView, positioned: .above, relativeTo: backdropBlurView) |
| | | } |
| | | |
| | | private func setupLayers() { |
| | | guard let rootLayer = layer else { return } |
| | | guard let rootLayer = layerHostView.layer else { return } |
| | | |
| | | rootLayer.masksToBounds = true |
| | | backgroundLayer.colors = [ |
| | | NSColor.windowBackgroundColor.withAlphaComponent(0.72).cgColor, |
| | | NSColor.controlBackgroundColor.withAlphaComponent(0.62).cgColor |
| | | NSColor.windowBackgroundColor.withAlphaComponent(0.58).cgColor, |
| | | NSColor.controlBackgroundColor.withAlphaComponent(0.48).cgColor |
| | | ] |
| | | backgroundLayer.startPoint = CGPoint(x: 0.5, y: 1.0) |
| | | backgroundLayer.endPoint = CGPoint(x: 0.5, y: 0.0) |
| | | rootLayer.addSublayer(backgroundLayer) |
| | | |
| | | glassLayer.cornerRadius = 24 |
| | | glassLayer.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.72).cgColor |
| | | glassLayer.borderColor = NSColor.separatorColor.withAlphaComponent(0.35).cgColor |
| | | glassLayer.borderWidth = 1 |
| | | glassLayer.shadowColor = NSColor.black.cgColor |
| | | glassLayer.shadowOpacity = 0.16 |
| | | glassLayer.shadowRadius = 24 |
| | | glassLayer.shadowOffset = CGSize(width: 0, height: -8) |
| | | safeAreaFillLayer.backgroundColor = NSColor.black.cgColor |
| | | rootLayer.addSublayer(safeAreaFillLayer) |
| | | |
| | | glassLayer.cornerRadius = 0 |
| | | glassLayer.backgroundColor = NSColor.clear.cgColor |
| | | glassLayer.borderColor = NSColor.clear.cgColor |
| | | glassLayer.borderWidth = 0 |
| | | glassLayer.shadowOpacity = 0 |
| | | rootLayer.addSublayer(glassLayer) |
| | | |
| | | laneLayer.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.50).cgColor |
| | | laneLayer.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.68).cgColor |
| | | laneLayer.cornerRadius = 16 |
| | | laneLayer.masksToBounds = true |
| | | glassLayer.addSublayer(laneLayer) |
| | | laneLayer.addSublayer(spaceLaneContentLayer) |
| | | |
| | | shelfLayer.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.42).cgColor |
| | | shelfLayer.backgroundColor = NSColor.clear.cgColor |
| | | shelfLayer.cornerRadius = 16 |
| | | shelfLayer.masksToBounds = true |
| | | glassLayer.addSublayer(shelfLayer) |
| | |
| | | badge.masksToBounds = true |
| | | |
| | | let selectedIndicator = CALayer() |
| | | selectedIndicator.cornerRadius = 2 |
| | | selectedIndicator.backgroundColor = (item.appGroupIndex == selectedAppGroupIndex |
| | | ? NSColor.controlAccentColor |
| | | : NSColor.clear |
| | | ).cgColor |
| | | selectedIndicator.isHidden = true |
| | | |
| | | container.addSublayer(background) |
| | | container.addSublayer(icon) |
| | | container.addSublayer(label) |
| | | container.addSublayer(badge) |
| | | container.addSublayer(selectedIndicator) |
| | | appShelfContentLayer.addSublayer(container) |
| | | |
| | | return AppShelfItemLayers( |
| | |
| | | container.name = column.app.name |
| | | container.cornerRadius = 14 |
| | | container.masksToBounds = true |
| | | container.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.34).cgColor |
| | | container.borderColor = NSColor.separatorColor.withAlphaComponent(0.28).cgColor |
| | | container.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.56).cgColor |
| | | container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor |
| | | container.borderWidth = 1 |
| | | |
| | | let header = CALayer() |
| | | header.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.58).cgColor |
| | | header.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.68).cgColor |
| | | |
| | | let appName = makeTextLayer( |
| | | string: column.app.name, |
| | |
| | | |
| | | backgroundLayer.frame = bounds |
| | | |
| | | let horizontalInset = max(36, bounds.width * 0.045) |
| | | let topInset = max(36, bounds.height * 0.055) |
| | | glassLayer.frame = bounds.insetBy(dx: horizontalInset, dy: topInset) |
| | | let safeAreaTopInset = topSafeAreaInset |
| | | safeAreaFillLayer.frame = CGRect( |
| | | x: 0, |
| | | y: max(0, bounds.height - safeAreaTopInset), |
| | | width: bounds.width, |
| | | height: safeAreaTopInset |
| | | ) |
| | | safeAreaFillLayer.isHidden = safeAreaTopInset <= 0.5 |
| | | glassLayer.frame = bounds |
| | | |
| | | let contentBounds = glassLayer.bounds |
| | | let horizontalMargin: CGFloat = 22 |
| | | let horizontalMargin = RootLayoutMetrics.horizontalMargin |
| | | let topMargin = safeAreaTopInset + RootLayoutMetrics.topContentMargin |
| | | let laneHeight = min(104, max(82, contentBounds.height * 0.12)) |
| | | let availableContentWidth = contentBounds.width - horizontalMargin * 2 |
| | | appShelfLayout = appShelfLayout(for: availableContentWidth) |
| | | let shelfHeight = appShelfHeight(forRows: appShelfLayout.rows) |
| | | laneLayer.frame = CGRect( |
| | | x: horizontalMargin, |
| | | y: contentBounds.height - horizontalMargin - laneHeight, |
| | | y: contentBounds.height - topMargin - laneHeight, |
| | | width: availableContentWidth, |
| | | height: laneHeight |
| | | ) |
| | | shelfLayer.frame = CGRect( |
| | | x: horizontalMargin, |
| | | y: laneLayer.frame.minY - 16 - shelfHeight, |
| | | y: laneLayer.frame.minY - RootLayoutMetrics.shelfGap - shelfHeight, |
| | | width: laneLayer.frame.width, |
| | | height: shelfHeight |
| | | ) |
| | | waterfallLayer.frame = CGRect( |
| | | x: shelfLayer.frame.minX, |
| | | y: 34, |
| | | y: RootLayoutMetrics.bottomMargin, |
| | | width: shelfLayer.frame.width, |
| | | height: shelfLayer.frame.minY - 48 |
| | | height: max(1, shelfLayer.frame.minY - RootLayoutMetrics.bottomMargin - RootLayoutMetrics.waterfallTopGap) |
| | | ) |
| | | |
| | | layoutSpaceLaneSegments() |
| | |
| | | layoutWaterfallColumns() |
| | | |
| | | CATransaction.commit() |
| | | } |
| | | |
| | | private var topSafeAreaInset: CGFloat { |
| | | max(0, window?.screen?.safeAreaInsets.top ?? safeAreaInsets.top) |
| | | } |
| | | |
| | | private func appShelfLayout(for availableWidth: CGFloat) -> AppShelfLayout { |
| | |
| | | : NSColor.controlAccentColor.withAlphaComponent(0.90) |
| | | ).cgColor |
| | | item.badgeLayer.frame = CGRect( |
| | | x: iconX + visualIconSize - badgeWidth + 3, |
| | | y: iconY + visualIconSize - 14, |
| | | x: iconX - 3, |
| | | y: iconY - 2, |
| | | width: badgeWidth, |
| | | height: 16 |
| | | ) |
| | | item.selectedIndicatorLayer.frame = CGRect( |
| | | x: bounds.midX - 8, |
| | | y: 0, |
| | | width: 16, |
| | | height: 4 |
| | | ) |
| | | item.selectedIndicatorLayer.backgroundColor = (isSelected |
| | | ? NSColor.controlAccentColor |
| | | : NSColor.clear |
| | | ).cgColor |
| | | item.selectedIndicatorLayer.frame = .zero |
| | | item.selectedIndicatorLayer.backgroundColor = NSColor.clear.cgColor |
| | | } |
| | | |
| | | private func appShelfRows() -> [[AppShelfItemLayers]] { |
| | |
| | | .sorted() |
| | | } |
| | | |
| | | private func firstWaterfallCard(inSpaceID spaceID: UInt64) -> WaterfallCardLayers? { |
| | | for column in waterfallColumns.sorted(by: { $0.column.appGroupIndex < $1.column.appGroupIndex }) { |
| | | if let card = column.cards |
| | | .sorted(by: { $0.item.windowIndex < $1.item.windowIndex }) |
| | | .first(where: { $0.item.primarySpaceID == spaceID }) { |
| | | return card |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | private func spaceFocusWindowCount(for appGroupIndex: Int) -> Int { |
| | | guard let activeSpaceFocusID else { return 0 } |
| | | |