import AppKit
|
import QuartzCore
|
import Darwin
|
import AlignerCore
|
|
enum QuickSwitchCommitSource: String {
|
case keyboard
|
case mouse
|
}
|
|
@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()
|
private let shelfLayer = CALayer()
|
private let appShelfContentLayer = CALayer()
|
private let waterfallLayer = CALayer()
|
private let waterfallContentLayer = CALayer()
|
private lazy var closeConfirmationLayers = makeCloseConfirmationLayers()
|
private let closeFeedbackLayer = CALayer()
|
private let closeFeedbackTextLayer = CATextLayer()
|
private let appIconProvider: any AppIconProviderProtocol = WorkspaceAppIconProvider()
|
private let skeletonThumbnailProvider = NativeSkeletonThumbnailProvider(size: NSSize(width: 160, height: 96))
|
private var screenshotSourcesByWindowID: [UInt32: ScreenshotResolutionSource] = [:]
|
private var screenshotSkippedReasonsByWindowID: [UInt32: String] = [:]
|
private var currentViewModel: QuickSwitchViewModel?
|
private var spaceLaneDisplayGroups: [SpaceLaneDisplayGroupLayers] = []
|
private var spaceLaneSegments: [SpaceLaneSegmentLayers] = []
|
private var spaceLaneContentWidth: CGFloat = 0
|
private var spaceLaneScrollOffset: CGFloat = 0
|
private var appShelfItems: [AppShelfItemLayers] = []
|
private var appShelfLayout = AppShelfLayout(rows: 0, iconSize: AppShelfMetrics.normalIconSize)
|
private var appShelfContentWidth: CGFloat = 0
|
private var appShelfScrollOffset: CGFloat = 0
|
private var appShelfTrackingArea: NSTrackingArea?
|
private var hoveredAppGroupIndex: Int?
|
private var hoveredWindowID: UInt32?
|
private var hoveredSpaceID: UInt64?
|
private var hoveredCloseTarget: CloseTarget?
|
private var pendingCloseTarget: CloseTarget?
|
private var pendingCloseRequest: QuickSwitchCloseRequest?
|
private var pendingCloseOptOut = false
|
private var closeConfirmationArrowEdge: CloseConfirmationArrowEdge?
|
private var closeConfirmationRequired = true
|
private var closeFeedback: CloseFeedback?
|
private var closeFeedbackHideTask: Task<Void, Never>?
|
private var hoverTarget: HoverTarget = .none
|
private var hoverTargetSource: HoverTargetSource = .event
|
private var hoveredSpaceLaneID: UInt64?
|
private var focusedSpaceID: UInt64?
|
private var debugHoveredAppGroupIndex: Int?
|
private var waterfallColumns: [WaterfallColumnLayers] = []
|
private var waterfallContentWidth: CGFloat = 0
|
private var waterfallScrollOffset: CGFloat = 0
|
private var waterfallColumnScrollOffsets: [Int: CGFloat] = [:]
|
private var currentSelection: QuickSwitchSelection?
|
private var columnSelectionHistory: [Int: Int] = [:]
|
private var keyboardCommandsApplied: [String] = []
|
private var lastKeyboardCommand: String?
|
private var tabIgnoredCount = 0
|
private var lastCommittedSelection: QuickSwitchSelection?
|
private var lastCommitSource: QuickSwitchCommitSource?
|
private var mouseCommandsApplied: [String] = []
|
private var lastMouseCommand: String?
|
private var lastClickedAppGroupIndex: Int?
|
private var lastAppShelfClickChangedSelection: Bool?
|
private var lastClickedWindowID: UInt32?
|
private var lastSpaceFocusedAppGroupIndex: Int?
|
private var lastSpaceLaneClickSpaceID: UInt64?
|
private var lastAppShelfScrollSyncedAppGroupIndex: Int?
|
private var firstFrameMeasurementStart: CFTimeInterval?
|
private var firstFrameLatencyMilliseconds: [Double] = []
|
private var keyboardResponseLatencyMilliseconds: [Double] = []
|
private var mouseResponseLatencyMilliseconds: [Double] = []
|
private var hoverResponseLatencyMilliseconds: [Double] = []
|
var onEscape: (() -> Void)?
|
var onCommitSelection: ((QuickSwitchSelection, QuickSwitchCommitSource) -> Void)?
|
var onRequestClose: ((QuickSwitchCloseRequest) -> Void)?
|
var onCloseConfirmationDisabled: (() -> Void)?
|
|
private enum CloseTarget: Equatable {
|
case app(appGroupIndex: Int)
|
case window(appGroupIndex: Int, windowIndex: Int, windowID: UInt32)
|
|
var kindDescription: String {
|
switch self {
|
case .app:
|
return "app"
|
case .window:
|
return "window"
|
}
|
}
|
}
|
|
private struct CloseButtonLayers {
|
let containerLayer: CALayer
|
let glyphLayer: CAShapeLayer
|
}
|
|
private struct CloseConfirmationLayers {
|
let containerLayer: CALayer
|
let backgroundLayer: CAShapeLayer
|
let titleLayer: CATextLayer
|
let messageLayer: CATextLayer
|
let checkboxLayer: CALayer
|
let checkboxMarkLayer: CATextLayer
|
let checkboxLabelLayer: CATextLayer
|
let confirmButtonLayer: CALayer
|
let confirmButtonTextLayer: CATextLayer
|
let cancelButtonLayer: CALayer
|
let cancelButtonTextLayer: CATextLayer
|
}
|
|
private struct CloseFeedback: Equatable, Sendable {
|
enum Kind: String, Sendable {
|
case progress
|
case failure
|
}
|
|
let kind: Kind
|
let message: String
|
let targetKind: String
|
let appGroupIndex: Int
|
let windowID: UInt32?
|
}
|
|
private enum CloseConfirmationArrowEdge: String {
|
case left
|
case right
|
}
|
|
private enum HoverTarget: Equatable {
|
case none
|
case space(UInt64)
|
case app(Int)
|
case window(appGroupIndex: Int, windowID: UInt32, spaceID: UInt64?)
|
|
var kindDescription: String {
|
switch self {
|
case .none:
|
return "none"
|
case .space:
|
return "space"
|
case .app:
|
return "app"
|
case .window:
|
return "window"
|
}
|
}
|
|
var appGroupIndex: Int? {
|
switch self {
|
case .app(let appGroupIndex):
|
return appGroupIndex
|
case .window(let appGroupIndex, _, _):
|
return appGroupIndex
|
case .none, .space:
|
return nil
|
}
|
}
|
|
var windowID: UInt32? {
|
switch self {
|
case .window(_, let windowID, _):
|
return windowID
|
case .none, .space, .app:
|
return nil
|
}
|
}
|
|
var spaceID: UInt64? {
|
switch self {
|
case .space(let spaceID):
|
return spaceID
|
case .window(_, _, let spaceID):
|
return spaceID
|
case .none, .app:
|
return nil
|
}
|
}
|
|
var spaceLaneID: UInt64? {
|
guard case .space(let spaceID) = self else { return nil }
|
return spaceID
|
}
|
|
var shouldAlignWaterfallWithAppShelf: Bool {
|
switch self {
|
case .app:
|
return true
|
case .none, .space, .window:
|
return false
|
}
|
}
|
}
|
|
private enum HoverTargetSource: String {
|
case event
|
case debug
|
}
|
|
private struct SpaceLaneSegmentLayers {
|
let space: QuickSwitchSpaceViewModel
|
let containerLayer: CALayer
|
let labelLayer: CATextLayer
|
let countLayer: CATextLayer
|
let appLayer: CATextLayer
|
let windowBlocks: [CALayer]
|
let fullscreenMarkerLayer: CALayer?
|
}
|
|
private struct SpaceLaneDisplayGroupLayers {
|
let display: QuickSwitchDisplayViewModel
|
let containerLayer: CALayer
|
let segments: [SpaceLaneSegmentLayers]
|
}
|
|
private struct AppShelfItemLayers {
|
let item: QuickSwitchAppShelfItemViewModel
|
let containerLayer: CALayer
|
let backgroundLayer: CALayer
|
let iconLayer: CALayer
|
let labelLayer: CATextLayer
|
let badgeLayer: CATextLayer
|
let selectedIndicatorLayer: CALayer
|
let closeButton: CloseButtonLayers
|
}
|
|
private struct AppShelfLayout {
|
let rows: Int
|
let iconSize: CGFloat
|
}
|
|
private struct WaterfallColumnLayers {
|
let column: QuickSwitchWaterfallColumnViewModel
|
let containerLayer: CALayer
|
let headerLayer: CALayer
|
let appNameLayer: CATextLayer
|
let countLayer: CATextLayer
|
let cards: [WaterfallCardLayers]
|
}
|
|
private struct WaterfallCardLayers {
|
let item: QuickSwitchWindowCardViewModel
|
let containerLayer: CALayer
|
let titleBarLayer: CALayer
|
let titleLayer: CATextLayer
|
let metaLayer: CATextLayer
|
let thumbnailLayer: CALayer
|
let appIconLayer: CALayer
|
let stateLayer: CATextLayer
|
let shineLayer: CAGradientLayer
|
let closeButton: CloseButtonLayers
|
}
|
|
private enum AppShelfMetrics {
|
static let normalIconSize: CGFloat = 56
|
static let hoverIconSize: CGFloat = 68
|
static let selectedIconSize: CGFloat = 68
|
static let minIconSize: CGFloat = 36
|
static let normalGap: CGFloat = 16
|
static let minGap: CGFloat = 8
|
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
|
static let columnMinWidth: CGFloat = 220
|
static let columnMaxWidth: CGFloat = 292
|
static let contentHorizontalPadding: CGFloat = 0
|
static let columnHorizontalPadding: CGFloat = 0
|
static let columnVerticalPadding: CGFloat = 0
|
static let columnHeaderTextInset: CGFloat = 12
|
static let headerHeight: CGFloat = 34
|
static let cardHeight: CGFloat = 138
|
static let cardGap: CGFloat = 12
|
static let revealPadding: CGFloat = 24
|
static let horizontalRevealPadding: CGFloat = 0
|
}
|
|
private enum WaterfallHorizontalIntent {
|
case reveal
|
case alignWithAppShelf(animated: Bool)
|
}
|
|
private enum WaterfallAlignmentMetrics {
|
static let tolerance: CGFloat = 1
|
static let animationDuration: CFTimeInterval = 0.18
|
static let animationTimingName = CAMediaTimingFunctionName.easeOut
|
}
|
|
private enum SpaceLaneMetrics {
|
static let horizontalPadding: CGFloat = 0
|
static let groupGap: CGFloat = 36
|
static let groupHorizontalPadding: CGFloat = 24
|
static let groupVerticalPadding: CGFloat = 6
|
static let segmentGap: CGFloat = 12
|
static let segmentMinWidth: CGFloat = 96
|
static let segmentMaxWidth: CGFloat = 150
|
}
|
|
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 var acceptsFirstResponder: Bool {
|
true
|
}
|
|
override func updateTrackingAreas() {
|
super.updateTrackingAreas()
|
|
if let appShelfTrackingArea {
|
removeTrackingArea(appShelfTrackingArea)
|
}
|
|
let trackingArea = NSTrackingArea(
|
rect: bounds,
|
options: [.activeAlways, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect],
|
owner: self,
|
userInfo: nil
|
)
|
addTrackingArea(trackingArea)
|
appShelfTrackingArea = trackingArea
|
}
|
|
override func layout() {
|
super.layout()
|
backdropBlurView.frame = bounds
|
layerHostView.frame = bounds
|
layoutLayers()
|
}
|
|
override func keyDown(with event: NSEvent) {
|
switch event.keyCode {
|
case TriggerKeyCode.escape where Self.shouldDismissOnEscape(event):
|
onEscape?()
|
case TriggerKeyCode.leftArrow:
|
performKeyboardCommand("left")
|
case TriggerKeyCode.rightArrow:
|
performKeyboardCommand("right")
|
case TriggerKeyCode.upArrow:
|
performKeyboardCommand("up")
|
case TriggerKeyCode.downArrow:
|
performKeyboardCommand("down")
|
case TriggerKeyCode.returnKey, TriggerKeyCode.keypadEnter:
|
performKeyboardCommand("enter")
|
case TriggerKeyCode.tab:
|
performKeyboardCommand("tab")
|
default:
|
super.keyDown(with: event)
|
}
|
}
|
|
override func scrollWheel(with event: NSEvent) {
|
refreshHoverTarget(at: event.locationInWindow)
|
|
let dominantDelta = abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY)
|
? event.scrollingDeltaX
|
: event.scrollingDeltaY
|
|
if shouldScrollSpaceLane(for: event), spaceLaneMaxScrollOffset > 0 {
|
spaceLaneScrollOffset = clampedSpaceLaneOffset(spaceLaneScrollOffset - dominantDelta)
|
needsLayout = true
|
return
|
}
|
|
if shouldScrollAppShelf(for: event), appShelfMaxScrollOffset > 0 {
|
let previousOffset = appShelfScrollOffset
|
appShelfScrollOffset = clampedAppShelfOffset(appShelfScrollOffset - dominantDelta)
|
syncWaterfallToVisibleAppShelfItem(previousOffset: previousOffset)
|
needsLayout = true
|
return
|
}
|
|
if shouldScrollWaterfall(for: event) {
|
if abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY), waterfallAlignmentScrollable {
|
waterfallScrollOffset = clampedWaterfallAlignmentOffset(waterfallScrollOffset - event.scrollingDeltaX)
|
needsLayout = true
|
return
|
}
|
|
let appGroupIndex = waterfallColumn(at: event.locationInWindow)?.column.appGroupIndex
|
?? effectiveSelection?.appGroupIndex
|
?? waterfallColumns.first?.column.appGroupIndex
|
if let appGroupIndex, waterfallMaxVerticalScrollOffset(for: appGroupIndex) > 0 {
|
let currentOffset = waterfallColumnScrollOffsets[appGroupIndex] ?? 0
|
waterfallColumnScrollOffsets[appGroupIndex] = clampedWaterfallColumnOffset(
|
currentOffset - event.scrollingDeltaY,
|
appGroupIndex: appGroupIndex
|
)
|
needsLayout = true
|
return
|
}
|
}
|
|
super.scrollWheel(with: event)
|
}
|
|
override func mouseMoved(with event: NSEvent) {
|
guard debugHoveredAppGroupIndex == nil else { return }
|
|
let closeTarget = closeButtonTarget(at: event.locationInWindow)
|
if hoveredCloseTarget != closeTarget {
|
hoveredCloseTarget = closeTarget
|
needsLayout = true
|
}
|
refreshHoverTarget(at: event.locationInWindow)
|
}
|
|
override func mouseExited(with event: NSEvent) {
|
guard debugHoveredAppGroupIndex == nil else { return }
|
hoveredCloseTarget = nil
|
setHoverTarget(.none)
|
}
|
|
override func mouseDown(with event: NSEvent) {
|
if handleCloseConfirmationMouseDown(at: event.locationInWindow) {
|
return
|
}
|
|
if let closeTarget = closeButtonTarget(at: event.locationInWindow) {
|
beginCloseConfirmation(for: closeTarget)
|
return
|
}
|
|
if let card = waterfallCard(at: event.locationInWindow) {
|
clickWindowCard(card)
|
return
|
}
|
|
if let segment = spaceLaneSegment(at: event.locationInWindow) {
|
clickSpaceLaneSegment(segment)
|
return
|
}
|
|
if let item = appShelfItem(at: event.locationInWindow) {
|
clickAppShelfItem(item)
|
return
|
}
|
|
DevelopmentDiagnostics.log("quickSwitch.view.mouseDown.miss", [
|
"x": event.locationInWindow.x,
|
"y": event.locationInWindow.y,
|
"bounds": NSStringFromRect(bounds)
|
])
|
super.mouseDown(with: event)
|
}
|
|
func setDebugHoveredAppGroupIndex(_ index: Int?) {
|
debugHoveredAppGroupIndex = index
|
setHoverTarget(.none, source: .debug)
|
hoveredAppGroupIndex = index
|
needsLayout = true
|
}
|
|
func performDebugKeyboardCommands(_ commands: [String]) {
|
guard !commands.isEmpty else { return }
|
|
layoutSubtreeIfNeeded()
|
for command in commands {
|
let measurementStart = CACurrentMediaTime()
|
performKeyboardCommand(command)
|
layoutSubtreeIfNeeded()
|
CATransaction.flush()
|
keyboardResponseLatencyMilliseconds.append(milliseconds(since: measurementStart))
|
}
|
}
|
|
func performDebugMouseCommands(_ commands: [String]) {
|
guard !commands.isEmpty else { return }
|
|
layoutSubtreeIfNeeded()
|
for command in commands {
|
let measurementStart = CACurrentMediaTime()
|
performMouseCommand(command)
|
layoutSubtreeIfNeeded()
|
CATransaction.flush()
|
let elapsed = milliseconds(since: measurementStart)
|
mouseResponseLatencyMilliseconds.append(elapsed)
|
if isHoverCommand(command) {
|
hoverResponseLatencyMilliseconds.append(elapsed)
|
}
|
if lastCommitSource == .mouse {
|
break
|
}
|
}
|
}
|
|
func beginPerformanceFirstFrameMeasurement() {
|
firstFrameMeasurementStart = CACurrentMediaTime()
|
}
|
|
func recordPerformanceFirstFrameIfNeeded() {
|
guard let firstFrameMeasurementStart else { return }
|
|
layoutSubtreeIfNeeded()
|
CATransaction.flush()
|
firstFrameLatencyMilliseconds.append(milliseconds(since: firstFrameMeasurementStart))
|
self.firstFrameMeasurementStart = nil
|
}
|
|
func resetPerformanceMeasurements() {
|
firstFrameMeasurementStart = nil
|
firstFrameLatencyMilliseconds = []
|
keyboardResponseLatencyMilliseconds = []
|
mouseResponseLatencyMilliseconds = []
|
hoverResponseLatencyMilliseconds = []
|
}
|
|
func setCloseConfirmationRequired(_ required: Bool) {
|
closeConfirmationRequired = required
|
if !required {
|
pendingCloseTarget = nil
|
pendingCloseRequest = nil
|
pendingCloseOptOut = false
|
closeConfirmationLayers.containerLayer.isHidden = true
|
closeConfirmationArrowEdge = nil
|
}
|
needsLayout = true
|
}
|
|
func showCloseProgress(for request: QuickSwitchCloseRequest) {
|
setCloseFeedback(
|
CloseFeedback(
|
kind: .progress,
|
message: "正在关闭...",
|
targetKind: request.kindDescription,
|
appGroupIndex: request.appGroupIndex,
|
windowID: request.windowID
|
),
|
autoHideAfter: nil
|
)
|
}
|
|
func showCloseFailure(message: String, for request: QuickSwitchCloseRequest) {
|
setCloseFeedback(
|
CloseFeedback(
|
kind: .failure,
|
message: message,
|
targetKind: request.kindDescription,
|
appGroupIndex: request.appGroupIndex,
|
windowID: request.windowID
|
),
|
autoHideAfter: 2.75
|
)
|
}
|
|
func clearCloseFeedback() {
|
closeFeedbackHideTask?.cancel()
|
closeFeedbackHideTask = nil
|
closeFeedback = nil
|
closeFeedbackLayer.isHidden = true
|
closeFeedbackLayer.opacity = 0
|
closeFeedbackTextLayer.string = ""
|
needsLayout = true
|
}
|
|
func applyScreenshot(_ resolution: ScreenshotResolution, for windowID: UInt32) {
|
screenshotSkippedReasonsByWindowID[windowID] = nil
|
screenshotSourcesByWindowID[windowID] = resolution.source
|
|
guard let card = waterfallCardsByWindowID()[windowID] else { return }
|
card.thumbnailLayer.contents = resolution.image
|
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
|
}
|
needsLayout = true
|
}
|
|
func markScreenshotNotRequested(for windowID: UInt32, reason: String) {
|
screenshotSourcesByWindowID[windowID] = nil
|
screenshotSkippedReasonsByWindowID[windowID] = reason
|
needsLayout = true
|
}
|
|
func markScreenshotsNotRequested(for viewModel: QuickSwitchViewModel, reason: String) {
|
for window in viewModel.waterfallColumns.flatMap({ $0.windows.map(\.window) }) {
|
markScreenshotNotRequested(for: window.id, reason: reason)
|
}
|
}
|
|
func apply(viewModel: QuickSwitchViewModel?) {
|
currentViewModel = viewModel
|
screenshotSourcesByWindowID = [:]
|
screenshotSkippedReasonsByWindowID = [:]
|
currentSelection = viewModel?.initialSelection
|
columnSelectionHistory = [:]
|
keyboardCommandsApplied = []
|
lastKeyboardCommand = nil
|
tabIgnoredCount = 0
|
lastCommittedSelection = nil
|
lastCommitSource = nil
|
mouseCommandsApplied = []
|
lastMouseCommand = nil
|
lastClickedAppGroupIndex = nil
|
lastAppShelfClickChangedSelection = nil
|
lastClickedWindowID = nil
|
lastSpaceFocusedAppGroupIndex = nil
|
lastSpaceLaneClickSpaceID = nil
|
lastAppShelfScrollSyncedAppGroupIndex = nil
|
focusedSpaceID = nil
|
hoveredCloseTarget = nil
|
pendingCloseTarget = nil
|
pendingCloseRequest = nil
|
pendingCloseOptOut = false
|
closeConfirmationLayers.containerLayer.isHidden = true
|
closeConfirmationArrowEdge = nil
|
clearCloseFeedback()
|
setHoverTarget(.none)
|
hoveredAppGroupIndex = debugHoveredAppGroupIndex
|
if let currentSelection {
|
columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
|
}
|
rebuildSpaceLaneSegments()
|
rebuildAppShelfItems()
|
rebuildWaterfallColumns()
|
needsLayout = true
|
ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: false))
|
needsLayout = true
|
}
|
|
func reportDictionary() -> [String: Any] {
|
layoutSubtreeIfNeeded()
|
let firstFrameP95 = percentile95(firstFrameLatencyMilliseconds)
|
let keyboardP95 = percentile95(keyboardResponseLatencyMilliseconds)
|
let mouseP95 = percentile95(mouseResponseLatencyMilliseconds)
|
let hoverP95 = percentile95(hoverResponseLatencyMilliseconds)
|
let firstFrameGatePassed = firstFrameP95.map { $0 < 150 } ?? false
|
let keyboardGatePassed = keyboardP95.map { $0 < 50 } ?? false
|
let hoverGatePassed = hoverP95.map { $0 <= 32 } ?? false
|
|
return [
|
"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),
|
"spaceLaneBackgroundColor": colorDictionary(from: laneLayer.backgroundColor),
|
"spaceLaneCornerRadius": Double(laneLayer.cornerRadius),
|
"spaceLaneContentWidth": Double(spaceLaneContentWidth),
|
"spaceLaneVisibleWidth": Double(laneLayer.bounds.width),
|
"spaceLaneMaxScrollOffset": Double(spaceLaneMaxScrollOffset),
|
"spaceLaneScrollable": spaceLaneMaxScrollOffset > 0,
|
"spaceLaneDisplayGroupCount": spaceLaneDisplayGroups.count,
|
"spaceLaneDisplayGroups": spaceLaneDisplayGroupReports(),
|
"spaceLaneSegmentCount": spaceLaneSegments.count,
|
"spaceLaneLabels": currentViewModel?.displays.flatMap { $0.spaces.map(\.label) } ?? [],
|
"spaceLaneSegments": spaceLaneSegmentReports(),
|
"hoverTargetKind": hoverTarget.kindDescription,
|
"hoverTargetSource": hoverTargetSource.rawValue,
|
"spaceLaneHoveredSpaceID": hoveredSpaceLaneID ?? NSNull(),
|
"spaceLaneFocusedSpaceID": focusedSpaceID ?? NSNull(),
|
"activeSpaceFocusID": activeSpaceFocusID ?? NSNull(),
|
"spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(),
|
"appHoverAssociatedSpaceIDs": appHoverAssociatedSpaceIDs.sorted(),
|
"appHoverAssociatedSpaceLabels": appHoverAssociatedSpaceLabels,
|
"windowHoverAssociatedSpaceID": windowHoverAssociatedSpaceID ?? NSNull(),
|
"lastSpaceFocusedAppGroupIndex": lastSpaceFocusedAppGroupIndex ?? NSNull(),
|
"lastSpaceLaneClickSpaceID": lastSpaceLaneClickSpaceID ?? NSNull(),
|
"lastAppShelfScrollSyncedAppGroupIndex": lastAppShelfScrollSyncedAppGroupIndex ?? NSNull(),
|
"spaceLaneCurrentLabels": currentViewModel?.displays.flatMap { display in
|
display.spaces.filter(\.isCurrent).map(\.label)
|
} ?? [],
|
"spaceLaneFullscreenLabels": currentViewModel?.displays.flatMap { display in
|
display.spaces.filter { $0.type == .fullscreen }.map(\.label)
|
} ?? [],
|
"appShelfFrame": dictionary(from: shelfLayer.frame),
|
"appShelfContentWidth": Double(appShelfContentWidth),
|
"appShelfVisibleWidth": Double(shelfLayer.bounds.width),
|
"appShelfScrollOffset": Double(appShelfScrollOffset),
|
"appShelfMaxScrollOffset": Double(appShelfMaxScrollOffset),
|
"appShelfScrollable": appShelfMaxScrollOffset > 0,
|
"appShelfItemCount": appShelfItems.count,
|
"appShelfNames": appShelfItems.map(\.item.app.name),
|
"appShelfHoveredIndex": effectiveHoveredAppGroupIndex ?? NSNull(),
|
"appShelfRows": appShelfLayout.rows,
|
"appShelfMaxRows": AppShelfMetrics.maxRows,
|
"appShelfIconSize": Double(appShelfLayout.iconSize),
|
"appShelfNormalIconSize": Double(AppShelfMetrics.normalIconSize),
|
"appShelfHoverIconSize": Double(AppShelfMetrics.hoverIconSize),
|
"appShelfSelectedIconSize": Double(AppShelfMetrics.selectedIconSize),
|
"appShelfMinIconSize": Double(AppShelfMetrics.minIconSize),
|
"appShelfItems": appShelfItemReports(),
|
"waterfallFrame": dictionary(from: waterfallLayer.frame),
|
"waterfallContentWidth": Double(waterfallContentWidth),
|
"waterfallVisibleWidth": Double(waterfallLayer.bounds.width),
|
"waterfallScrollOffset": Double(waterfallScrollOffset),
|
"waterfallMaxScrollOffset": Double(waterfallMaxScrollOffset),
|
"waterfallAlignmentMinScrollOffset": Double(waterfallAlignmentScrollRange.lowerBound),
|
"waterfallAlignmentMaxScrollOffset": Double(waterfallAlignmentScrollRange.upperBound),
|
"waterfallScrollable": waterfallAlignmentScrollable,
|
"waterfallColumnCount": waterfallColumns.count,
|
"waterfallColumnNames": waterfallColumns.map(\.column.app.name),
|
"waterfallClipsToBounds": waterfallLayer.masksToBounds,
|
"waterfallColumns": waterfallColumnReports(),
|
"appColumnAlignmentReports": appColumnAlignmentReports(),
|
"waterfallAlignmentTolerance": Double(WaterfallAlignmentMetrics.tolerance),
|
"waterfallAlignmentAnimationDurationMilliseconds": Double(WaterfallAlignmentMetrics.animationDuration * 1_000),
|
"waterfallAlignmentAnimationTimingName": "easeOut",
|
"reduceMotionEffective": NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
|
"selectedAppGroupIndex": effectiveSelection?.appGroupIndex ?? NSNull(),
|
"selectedWindowIndex": effectiveSelection?.windowIndex ?? NSNull(),
|
"selectedWindowID": effectiveSelection?.windowID ?? NSNull(),
|
"keyboardCommandsApplied": keyboardCommandsApplied,
|
"lastKeyboardCommand": lastKeyboardCommand ?? NSNull(),
|
"tabIgnoredCount": tabIgnoredCount,
|
"lastCommittedAppGroupIndex": lastCommittedSelection?.appGroupIndex ?? NSNull(),
|
"lastCommittedWindowIndex": lastCommittedSelection?.windowIndex ?? NSNull(),
|
"lastCommittedWindowID": lastCommittedSelection?.windowID ?? NSNull(),
|
"lastCommitSource": lastCommitSource?.rawValue ?? NSNull(),
|
"hoveredAppGroupIndex": effectiveHoveredAppGroupIndex ?? NSNull(),
|
"hoveredWindowID": hoveredWindowID ?? NSNull(),
|
"hoveredSpaceID": hoveredSpaceID ?? NSNull(),
|
"hoveredCloseTargetKind": hoveredCloseTarget?.kindDescription ?? NSNull(),
|
"pendingCloseTargetKind": pendingCloseTarget?.kindDescription ?? NSNull(),
|
"pendingCloseOptOut": pendingCloseOptOut,
|
"closeConfirmationRequired": closeConfirmationRequired,
|
"closeConfirmationVisible": !closeConfirmationLayers.containerLayer.isHidden,
|
"closeConfirmationFrame": dictionary(from: closeConfirmationLayers.containerLayer.frame),
|
"closeConfirmationArrowEdge": closeConfirmationArrowEdge?.rawValue ?? NSNull(),
|
"closeFeedbackVisible": closeFeedback != nil && !closeFeedbackLayer.isHidden,
|
"closeFeedbackKind": closeFeedback?.kind.rawValue ?? NSNull(),
|
"closeFeedbackMessage": closeFeedback?.message ?? NSNull(),
|
"closeFeedbackTargetKind": closeFeedback?.targetKind ?? NSNull(),
|
"closeFeedbackAppGroupIndex": closeFeedback?.appGroupIndex ?? NSNull(),
|
"closeFeedbackWindowID": closeFeedback?.windowID ?? NSNull(),
|
"closeFeedbackFrame": dictionary(from: closeFeedbackLayer.frame),
|
"mouseCommandsApplied": mouseCommandsApplied,
|
"lastMouseCommand": lastMouseCommand ?? NSNull(),
|
"lastClickedAppGroupIndex": lastClickedAppGroupIndex ?? NSNull(),
|
"lastAppShelfClickChangedSelection": lastAppShelfClickChangedSelection ?? NSNull(),
|
"lastClickedWindowID": lastClickedWindowID ?? NSNull(),
|
"columnSelectionHistory": columnSelectionHistoryReports(),
|
"screenshotEligibleCount": screenshotEligibleWindows.count,
|
"screenshotResolvedCount": screenshotSourcesByWindowID.count,
|
"screenshotPendingCount": screenshotPendingWindows.count,
|
"screenshotNotRequestedCount": screenshotSkippedReasonsByWindowID.count,
|
"realScreenshotCount": screenshotSourcesByWindowID.values.filter { source in
|
if case .realScreenshot = source { return true }
|
return false
|
}.count,
|
"skeletonFallbackCount": screenshotSourcesByWindowID.values.filter { source in
|
if case .skeletonFallback = source { return true }
|
return false
|
}.count,
|
"firstFrameLatencySampleCount": firstFrameLatencyMilliseconds.count,
|
"firstFrameLatencyP95Milliseconds": jsonNumber(firstFrameP95),
|
"firstFrameLatencyMaxMilliseconds": jsonNumber(firstFrameLatencyMilliseconds.max()),
|
"keyboardResponseSampleCount": keyboardResponseLatencyMilliseconds.count,
|
"keyboardResponseP95Milliseconds": jsonNumber(keyboardP95),
|
"keyboardResponseMaxMilliseconds": jsonNumber(keyboardResponseLatencyMilliseconds.max()),
|
"mouseResponseSampleCount": mouseResponseLatencyMilliseconds.count,
|
"mouseResponseP95Milliseconds": jsonNumber(mouseP95),
|
"mouseResponseMaxMilliseconds": jsonNumber(mouseResponseLatencyMilliseconds.max()),
|
"hoverResponseSampleCount": hoverResponseLatencyMilliseconds.count,
|
"hoverResponseP95Milliseconds": jsonNumber(hoverP95),
|
"hoverResponseMaxMilliseconds": jsonNumber(hoverResponseLatencyMilliseconds.max()),
|
"performanceThresholds": [
|
"firstFrameMilliseconds": 150,
|
"keyboardP95Milliseconds": 50,
|
"hoverP95Milliseconds": 32
|
],
|
"interactionPerformanceGatePassed": firstFrameGatePassed && keyboardGatePassed && hoverGatePassed
|
]
|
}
|
|
private static func shouldDismissOnEscape(_ event: NSEvent) -> Bool {
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
return flags.isDisjoint(with: [.command, .option, .control])
|
}
|
|
private func performKeyboardCommand(_ rawCommand: String) {
|
let command = rawCommand
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.lowercased()
|
guard !command.isEmpty else { return }
|
|
switch command {
|
case "left":
|
recordKeyboardCommand("left")
|
moveSelection(.left)
|
case "right":
|
recordKeyboardCommand("right")
|
moveSelection(.right)
|
case "up":
|
recordKeyboardCommand("up")
|
moveSelection(.up)
|
case "down":
|
recordKeyboardCommand("down")
|
moveSelection(.down)
|
case "enter", "return", "commit":
|
recordKeyboardCommand("enter")
|
commitCurrentSelection()
|
case "tab":
|
tabIgnoredCount += 1
|
recordKeyboardCommand("tabIgnored")
|
default:
|
recordKeyboardCommand("unknown:\(command)")
|
}
|
}
|
|
private func recordKeyboardCommand(_ command: String) {
|
lastKeyboardCommand = command
|
keyboardCommandsApplied.append(command)
|
}
|
|
private func performMouseCommand(_ rawCommand: String) {
|
let command = rawCommand
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.lowercased()
|
guard !command.isEmpty else { return }
|
|
let parts = command.split(separator: ":").map(String.init)
|
switch parts.first {
|
case "hover-app" where parts.count == 2:
|
guard let appGroupIndex = Int(parts[1]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("hover-app:\(appGroupIndex)")
|
hoverAppGroup(appGroupIndex)
|
case "click-app" where parts.count == 2:
|
guard let appGroupIndex = Int(parts[1]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("click-app:\(appGroupIndex)")
|
clickAppShelfItem(appGroupIndex: appGroupIndex)
|
case "hover-app-close" where parts.count == 2:
|
guard let appGroupIndex = Int(parts[1]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("hover-app-close:\(appGroupIndex)")
|
hoverAppCloseButton(appGroupIndex)
|
case "click-app-close" where parts.count == 2:
|
guard let appGroupIndex = Int(parts[1]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("click-app-close:\(appGroupIndex)")
|
clickAppCloseButton(appGroupIndex)
|
case "scroll-app-shelf" where parts.count == 2:
|
guard let targetOffset = Double(parts[1]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("scroll-app-shelf:\(parts[1])")
|
scrollAppShelf(to: CGFloat(targetOffset))
|
case "hover-space" where parts.count == 2:
|
guard let spaceID = UInt64(parts[1]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("hover-space:\(spaceID)")
|
if let segment = spaceLaneSegment(spaceID: spaceID) {
|
hoverSpaceLaneSegment(segment, source: .debug)
|
}
|
case "click-space" where parts.count == 2:
|
guard let spaceID = UInt64(parts[1]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("click-space:\(spaceID)")
|
if let segment = spaceLaneSegment(spaceID: spaceID) {
|
clickSpaceLaneSegment(segment)
|
}
|
case "hover-card" where parts.count == 3:
|
guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("hover-card:\(appGroupIndex):\(windowIndex)")
|
if let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) {
|
hoverWindowCard(card, source: .debug)
|
}
|
case "click-card" where parts.count == 3:
|
guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("click-card:\(appGroupIndex):\(windowIndex)")
|
if let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) {
|
clickWindowCard(card)
|
}
|
case "hover-card-close" where parts.count == 3:
|
guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("hover-card-close:\(appGroupIndex):\(windowIndex)")
|
hoverCardCloseButton(appGroupIndex: appGroupIndex, windowIndex: windowIndex)
|
case "click-card-close" where parts.count == 3:
|
guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else {
|
recordMouseCommand("invalid:\(command)")
|
return
|
}
|
recordMouseCommand("click-card-close:\(appGroupIndex):\(windowIndex)")
|
clickCardCloseButton(appGroupIndex: appGroupIndex, windowIndex: windowIndex)
|
case "toggle-close-optout":
|
recordMouseCommand("toggle-close-optout")
|
toggleCloseConfirmationOptOut()
|
case "confirm-close":
|
recordMouseCommand("confirm-close")
|
confirmPendingClose()
|
case "cancel-close":
|
recordMouseCommand("cancel-close")
|
cancelPendingClose()
|
default:
|
recordMouseCommand("unknown:\(command)")
|
}
|
}
|
|
private func recordMouseCommand(_ command: String) {
|
lastMouseCommand = command
|
mouseCommandsApplied.append(command)
|
}
|
|
private func isHoverCommand(_ rawCommand: String) -> Bool {
|
let command = rawCommand
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.lowercased()
|
return command.hasPrefix("hover-")
|
}
|
|
private func hoverAppGroup(_ appGroupIndex: Int) {
|
updateHoverState(
|
appGroupIndex: appGroupIndex,
|
windowID: nil,
|
spaceID: nil,
|
alignsWaterfallWithAppShelf: true,
|
source: .debug
|
)
|
}
|
|
private func hoverAppCloseButton(_ appGroupIndex: Int) {
|
hoverAppGroup(appGroupIndex)
|
hoveredCloseTarget = .app(appGroupIndex: appGroupIndex)
|
needsLayout = true
|
}
|
|
private func clickAppCloseButton(_ appGroupIndex: Int) {
|
hoverAppCloseButton(appGroupIndex)
|
beginCloseConfirmation(for: .app(appGroupIndex: appGroupIndex))
|
}
|
|
private func hoverWindowCard(_ card: WaterfallCardLayers, source: HoverTargetSource = .event) {
|
updateHoverState(
|
appGroupIndex: card.item.appGroupIndex,
|
windowID: card.item.window.id,
|
spaceID: card.item.primarySpaceID,
|
source: source
|
)
|
}
|
|
private func hoverCardCloseButton(appGroupIndex: Int, windowIndex: Int) {
|
guard let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) else { return }
|
hoverWindowCard(card, source: .debug)
|
hoveredCloseTarget = .window(
|
appGroupIndex: appGroupIndex,
|
windowIndex: windowIndex,
|
windowID: card.item.window.id
|
)
|
needsLayout = true
|
}
|
|
private func clickCardCloseButton(appGroupIndex: Int, windowIndex: Int) {
|
guard let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) else { return }
|
hoverCardCloseButton(appGroupIndex: appGroupIndex, windowIndex: windowIndex)
|
beginCloseConfirmation(
|
for: .window(
|
appGroupIndex: appGroupIndex,
|
windowIndex: windowIndex,
|
windowID: card.item.window.id
|
)
|
)
|
}
|
|
private func hoverSpaceLaneSegment(_ segment: SpaceLaneSegmentLayers, source: HoverTargetSource = .event) {
|
focusSpace(segment.space.id, persistent: false, source: source)
|
}
|
|
private func clickSpaceLaneSegment(_ segment: SpaceLaneSegmentLayers) {
|
DevelopmentDiagnostics.log("quickSwitch.view.clickSpaceLane", [
|
"spaceID": segment.space.id,
|
"label": segment.space.label
|
])
|
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, source: HoverTargetSource = .event) {
|
setHoverTarget(.space(spaceID), source: source)
|
if persistent {
|
focusedSpaceID = spaceID
|
lastSpaceLaneClickSpaceID = spaceID
|
}
|
|
let appGroupIndexes = spaceFocusedAppGroupIndexes(for: spaceID)
|
lastSpaceFocusedAppGroupIndex = appGroupIndexes.first
|
if let firstAppGroupIndex = appGroupIndexes.first {
|
ensureAppShelfItemVisible(firstAppGroupIndex)
|
ensureWaterfallColumnVisible(firstAppGroupIndex)
|
}
|
needsLayout = true
|
}
|
|
private func clickAppShelfItem(_ item: AppShelfItemLayers) {
|
clickAppShelfItem(appGroupIndex: item.item.appGroupIndex)
|
}
|
|
private func clickAppShelfItem(appGroupIndex: Int) {
|
guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }) else {
|
DevelopmentDiagnostics.log("quickSwitch.view.clickAppShelf.missingColumn", [
|
"appGroupIndex": appGroupIndex
|
])
|
return
|
}
|
|
let previousSelection = effectiveSelection
|
lastClickedAppGroupIndex = appGroupIndex
|
DevelopmentDiagnostics.log("quickSwitch.view.clickAppShelf", [
|
"appGroupIndex": appGroupIndex,
|
"appName": column.column.app.name,
|
"windowCount": column.cards.count
|
])
|
if let firstCard = column.cards.first {
|
let nextSelection = QuickSwitchSelection(
|
appGroupIndex: firstCard.item.appGroupIndex,
|
windowIndex: firstCard.item.windowIndex,
|
windowID: firstCard.item.window.id
|
)
|
currentSelection = nextSelection
|
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
|
}
|
}
|
|
private func scrollAppShelf(to targetOffset: CGFloat) {
|
layoutSubtreeIfNeeded()
|
let previousOffset = appShelfScrollOffset
|
appShelfScrollOffset = clampedAppShelfOffset(targetOffset)
|
syncWaterfallToVisibleAppShelfItem(previousOffset: previousOffset)
|
needsLayout = true
|
}
|
|
private func syncWaterfallToVisibleAppShelfItem(previousOffset: CGFloat) {
|
guard appShelfMaxScrollOffset > 0 else { return }
|
|
let visibleMinX = appShelfScrollOffset
|
let visibleMaxX = appShelfScrollOffset + shelfLayer.bounds.width
|
let visibleMidX = (visibleMinX + visibleMaxX) / 2
|
let candidates = appShelfItems.filter { item in
|
item.containerLayer.frame.maxX >= visibleMinX
|
&& item.containerLayer.frame.minX <= visibleMaxX
|
}
|
guard !candidates.isEmpty else { return }
|
|
let movingRight = appShelfScrollOffset > previousOffset
|
let best = candidates.sorted { lhs, rhs in
|
let lhsDistance = abs(lhs.containerLayer.frame.midX - visibleMidX)
|
let rhsDistance = abs(rhs.containerLayer.frame.midX - visibleMidX)
|
if lhsDistance != rhsDistance {
|
return lhsDistance < rhsDistance
|
}
|
return movingRight
|
? lhs.item.appGroupIndex > rhs.item.appGroupIndex
|
: lhs.item.appGroupIndex < rhs.item.appGroupIndex
|
}.first
|
|
guard let best else { return }
|
lastAppShelfScrollSyncedAppGroupIndex = best.item.appGroupIndex
|
ensureWaterfallColumnVisible(best.item.appGroupIndex)
|
}
|
|
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.commitMouseWindow", [
|
"trigger": trigger,
|
"appGroupIndex": selection.appGroupIndex,
|
"windowIndex": selection.windowIndex,
|
"windowID": selection.windowID,
|
"appName": card.item.window.app.name,
|
"appPID": card.item.window.app.processIdentifier,
|
"titleHash": DevelopmentDiagnostics.stableFingerprint(card.item.window.title),
|
"titleLength": card.item.window.title.count,
|
"titleIsEmpty": card.item.window.title.isEmpty,
|
"isMinimized": card.item.window.isMinimized,
|
"isFullscreen": card.item.window.isFullscreen,
|
"identifierSource": String(describing: card.item.window.identifierSource)
|
])
|
currentSelection = selection
|
columnSelectionHistory[selection.appGroupIndex] = selection.windowIndex
|
lastClickedWindowID = selection.windowID
|
lastCommittedSelection = selection
|
lastCommitSource = .mouse
|
ensureCurrentSelectionVisible()
|
onCommitSelection?(selection, .mouse)
|
}
|
|
private func updateHoverState(
|
appGroupIndex: Int?,
|
windowID: UInt32?,
|
spaceID: UInt64?,
|
alignsWaterfallWithAppShelf: Bool = false,
|
source: HoverTargetSource = .event
|
) {
|
let target: HoverTarget
|
if let appGroupIndex, let windowID {
|
target = .window(appGroupIndex: appGroupIndex, windowID: windowID, spaceID: spaceID)
|
} else if let appGroupIndex {
|
target = .app(appGroupIndex)
|
} else {
|
target = .none
|
}
|
|
setHoverTarget(target, source: source, alignsWaterfallWithAppShelf: alignsWaterfallWithAppShelf)
|
}
|
|
private func refreshHoverTarget(at windowLocation: CGPoint) {
|
guard debugHoveredAppGroupIndex == nil else { return }
|
let target = resolveHoverTarget(at: windowLocation)
|
setHoverTarget(target, alignsWaterfallWithAppShelf: target.shouldAlignWaterfallWithAppShelf)
|
}
|
|
private func resolveHoverTarget(at windowLocation: CGPoint) -> HoverTarget {
|
if let segment = spaceLaneSegment(at: windowLocation) {
|
return .space(segment.space.id)
|
}
|
|
if let card = waterfallCard(at: windowLocation) {
|
return .window(
|
appGroupIndex: card.item.appGroupIndex,
|
windowID: card.item.window.id,
|
spaceID: card.item.primarySpaceID
|
)
|
}
|
|
if let item = appShelfItem(at: windowLocation) {
|
return .app(item.item.appGroupIndex)
|
}
|
|
return .none
|
}
|
|
private func setHoverTarget(
|
_ target: HoverTarget,
|
source: HoverTargetSource = .event,
|
alignsWaterfallWithAppShelf: Bool = false
|
) {
|
let previousTarget = hoverTarget
|
let previousSource = hoverTargetSource
|
hoverTarget = target
|
hoverTargetSource = source
|
|
hoveredAppGroupIndex = target.appGroupIndex
|
hoveredWindowID = target.windowID
|
hoveredSpaceID = target.spaceID
|
hoveredSpaceLaneID = target.spaceLaneID
|
|
if alignsWaterfallWithAppShelf, let appGroupIndex = target.appGroupIndex {
|
alignWaterfallColumnWithAppShelfIcon(appGroupIndex: appGroupIndex, animated: true)
|
}
|
|
guard previousTarget != target || previousSource != source else { return }
|
needsLayout = true
|
}
|
|
private func moveSelection(_ direction: QuickSwitchKeyboardDirection) {
|
guard let currentViewModel,
|
let snapshot = snapshotForFocus(from: currentViewModel),
|
let current = currentSelection ?? currentViewModel.initialSelection,
|
let next = QuickSwitchFocusPolicy.nextSelection(
|
from: current,
|
in: snapshot,
|
direction: direction,
|
columnHistory: columnSelectionHistory
|
)
|
else {
|
DevelopmentDiagnostics.log("quickSwitch.view.moveSelection.blocked", [
|
"direction": String(describing: direction),
|
"hasViewModel": currentViewModel != nil,
|
"hasSelection": (currentSelection ?? currentViewModel?.initialSelection) != nil
|
])
|
return
|
}
|
|
columnSelectionHistory[current.appGroupIndex] = current.windowIndex
|
currentSelection = next
|
columnSelectionHistory[next.appGroupIndex] = next.windowIndex
|
ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
|
DevelopmentDiagnostics.log("quickSwitch.view.moveSelection", [
|
"direction": String(describing: direction),
|
"appGroupIndex": next.appGroupIndex,
|
"windowIndex": next.windowIndex,
|
"windowID": next.windowID
|
])
|
needsLayout = true
|
}
|
|
private func commitCurrentSelection() {
|
guard let selection = effectiveSelection else {
|
DevelopmentDiagnostics.log("quickSwitch.view.commitCurrentSelection.blocked")
|
return
|
}
|
|
currentSelection = selection
|
columnSelectionHistory[selection.appGroupIndex] = selection.windowIndex
|
lastCommittedSelection = selection
|
lastCommitSource = .keyboard
|
ensureCurrentSelectionVisible()
|
DevelopmentDiagnostics.log("quickSwitch.view.commitCurrentSelection", [
|
"appGroupIndex": selection.appGroupIndex,
|
"windowIndex": selection.windowIndex,
|
"windowID": selection.windowID
|
])
|
onCommitSelection?(selection, .keyboard)
|
}
|
|
private var effectiveSelection: QuickSwitchSelection? {
|
currentSelection ?? currentViewModel?.initialSelection
|
}
|
|
private func ensureCurrentSelectionVisible(horizontalIntent: WaterfallHorizontalIntent = .reveal) {
|
guard let selection = effectiveSelection else { return }
|
|
layoutSubtreeIfNeeded()
|
ensureAppShelfSelectionVisible(selection)
|
ensureWaterfallSelectionVisible(selection, horizontalIntent: horizontalIntent)
|
needsLayout = true
|
layoutSubtreeIfNeeded()
|
}
|
|
private func ensureAppShelfSelectionVisible(_ selection: QuickSwitchSelection) {
|
ensureAppShelfItemVisible(selection.appGroupIndex)
|
}
|
|
private func ensureAppShelfItemVisible(_ appGroupIndex: Int) {
|
guard let item = appShelfItems.first(where: { $0.item.appGroupIndex == appGroupIndex }) else {
|
return
|
}
|
|
appShelfScrollOffset = clampedAppShelfOffset(
|
scrollOffset(
|
makingVisible: item.containerLayer.frame,
|
currentOffset: appShelfScrollOffset,
|
visibleWidth: shelfLayer.bounds.width,
|
maxOffset: appShelfMaxScrollOffset,
|
inset: WaterfallMetrics.revealPadding
|
)
|
)
|
}
|
|
private func ensureWaterfallSelectionVisible(
|
_ selection: QuickSwitchSelection,
|
horizontalIntent: WaterfallHorizontalIntent = .reveal
|
) {
|
guard
|
let column = waterfallColumns.first(where: { $0.column.appGroupIndex == selection.appGroupIndex })
|
else {
|
return
|
}
|
|
switch horizontalIntent {
|
case .reveal:
|
ensureWaterfallColumnVisible(column)
|
case .alignWithAppShelf(let animated):
|
alignWaterfallColumnWithAppShelfIcon(appGroupIndex: selection.appGroupIndex, animated: animated)
|
}
|
ensureWaterfallCardVisibleVertically(selection)
|
}
|
|
private func ensureWaterfallCardVisibleVertically(_ selection: QuickSwitchSelection) {
|
let visibleHeight = max(
|
1,
|
waterfallLayer.bounds.height
|
- WaterfallMetrics.headerHeight
|
- WaterfallMetrics.columnVerticalPadding * 2
|
)
|
let cardStride = WaterfallMetrics.cardHeight + WaterfallMetrics.cardGap
|
let cardStart = CGFloat(selection.windowIndex) * cardStride
|
let cardEnd = cardStart + WaterfallMetrics.cardHeight
|
let currentOffset = waterfallColumnScrollOffsets[selection.appGroupIndex] ?? 0
|
let visibleStart = currentOffset
|
let visibleEnd = currentOffset + visibleHeight
|
var nextOffset = currentOffset
|
|
if cardStart < visibleStart + WaterfallMetrics.revealPadding {
|
nextOffset = cardStart - WaterfallMetrics.revealPadding
|
} else if cardEnd > visibleEnd - WaterfallMetrics.revealPadding {
|
nextOffset = cardEnd - visibleHeight + WaterfallMetrics.revealPadding
|
}
|
|
waterfallColumnScrollOffsets[selection.appGroupIndex] = clampedWaterfallColumnOffset(
|
nextOffset,
|
appGroupIndex: selection.appGroupIndex
|
)
|
}
|
|
private func alignWaterfallColumnWithAppShelfIcon(appGroupIndex: Int, animated: Bool) {
|
guard
|
let item = appShelfItems.first(where: { $0.item.appGroupIndex == appGroupIndex }),
|
let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex })
|
else {
|
return
|
}
|
|
let targetOffset = targetWaterfallOffsetAligning(column: column, to: item)
|
setWaterfallScrollOffset(targetOffset, animated: animated)
|
}
|
|
private func appShelfIconCenterXInWaterfallViewport(for item: AppShelfItemLayers) -> CGFloat {
|
let iconCenterInShelfViewport = item.containerLayer.frame.minX
|
+ item.iconLayer.frame.midX
|
- appShelfScrollOffset
|
return shelfLayer.frame.minX + iconCenterInShelfViewport - waterfallLayer.frame.minX
|
}
|
|
private func targetWaterfallOffsetAligning(
|
column: WaterfallColumnLayers,
|
to item: AppShelfItemLayers
|
) -> CGFloat {
|
column.containerLayer.frame.midX - appShelfIconCenterXInWaterfallViewport(for: item)
|
}
|
|
private func ensureWaterfallColumnVisible(_ appGroupIndex: Int) {
|
guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }) else {
|
return
|
}
|
|
ensureWaterfallColumnVisible(column)
|
}
|
|
private func ensureWaterfallColumnVisible(_ column: WaterfallColumnLayers) {
|
waterfallScrollOffset = clampedWaterfallOffset(
|
scrollOffset(
|
makingVisible: column.containerLayer.frame,
|
currentOffset: waterfallScrollOffset,
|
visibleWidth: waterfallLayer.bounds.width,
|
maxOffset: waterfallMaxScrollOffset,
|
inset: WaterfallMetrics.horizontalRevealPadding
|
)
|
)
|
}
|
|
private var waterfallAlignmentScrollRange: ClosedRange<CGFloat> {
|
var minimumOffset: CGFloat = 0
|
var maximumOffset = waterfallMaxScrollOffset
|
|
for item in appShelfItems {
|
guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == item.item.appGroupIndex }) else {
|
continue
|
}
|
|
let targetOffset = targetWaterfallOffsetAligning(column: column, to: item)
|
minimumOffset = min(minimumOffset, targetOffset)
|
maximumOffset = max(maximumOffset, targetOffset)
|
}
|
|
return minimumOffset...max(minimumOffset, maximumOffset)
|
}
|
|
private var waterfallAlignmentScrollable: Bool {
|
let range = waterfallAlignmentScrollRange
|
return range.upperBound - range.lowerBound > 0.5
|
}
|
|
private func clampedWaterfallAlignmentOffset(_ offset: CGFloat) -> CGFloat {
|
let range = waterfallAlignmentScrollRange
|
return min(max(range.lowerBound, offset), range.upperBound)
|
}
|
|
private func setWaterfallScrollOffset(_ offset: CGFloat, animated: Bool) {
|
let nextOffset = clampedWaterfallAlignmentOffset(offset)
|
let previousPositionX = waterfallContentLayer.presentation()?.position.x
|
?? waterfallContentLayer.position.x
|
|
waterfallScrollOffset = nextOffset
|
CATransaction.begin()
|
CATransaction.setDisableActions(true)
|
waterfallContentLayer.frame = CGRect(
|
x: -waterfallScrollOffset,
|
y: 0,
|
width: waterfallContentWidth,
|
height: waterfallLayer.bounds.height
|
)
|
CATransaction.commit()
|
|
let nextPositionX = waterfallContentLayer.position.x
|
guard animated,
|
!NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
|
abs(previousPositionX - nextPositionX) > 0.5
|
else {
|
waterfallContentLayer.removeAnimation(forKey: "waterfallColumnAlignment")
|
return
|
}
|
|
let animation = CABasicAnimation(keyPath: "position.x")
|
animation.fromValue = previousPositionX
|
animation.toValue = nextPositionX
|
animation.duration = WaterfallAlignmentMetrics.animationDuration
|
animation.timingFunction = CAMediaTimingFunction(name: WaterfallAlignmentMetrics.animationTimingName)
|
animation.isRemovedOnCompletion = true
|
waterfallContentLayer.add(animation, forKey: "waterfallColumnAlignment")
|
}
|
|
private func scrollOffset(
|
makingVisible frame: CGRect,
|
currentOffset: CGFloat,
|
visibleWidth: CGFloat,
|
maxOffset: CGFloat,
|
inset: CGFloat
|
) -> CGFloat {
|
guard visibleWidth > 0, maxOffset > 0 else { return currentOffset }
|
|
let visibleMinX = currentOffset
|
let visibleMaxX = currentOffset + visibleWidth
|
var nextOffset = currentOffset
|
|
if frame.minX < visibleMinX + inset {
|
nextOffset = frame.minX - inset
|
} else if frame.maxX > visibleMaxX - inset {
|
nextOffset = frame.maxX - visibleWidth + inset
|
}
|
|
return min(max(0, nextOffset), maxOffset)
|
}
|
|
private func snapshotForFocus(from viewModel: QuickSwitchViewModel) -> QuickSwitchSnapshot? {
|
let appGroups = viewModel.waterfallColumns.map { column in
|
QuickSwitchAppGroup(
|
app: column.app,
|
windows: column.windows.map { card in
|
QuickSwitchWindowItem(
|
window: card.window,
|
primarySpaceID: card.primarySpaceID
|
)
|
}
|
)
|
}
|
guard !appGroups.isEmpty else { return nil }
|
|
let displays = viewModel.displays.map { display in
|
AlignerDisplay(
|
uuid: display.displayUUID,
|
physical: display.physical,
|
spaces: display.spaces.map { space in
|
AlignerSpace(
|
id: space.id,
|
type: space.type,
|
displayUUID: space.displayUUID,
|
index: space.index
|
)
|
}
|
)
|
}
|
|
return QuickSwitchSnapshot(displays: displays, appGroups: appGroups)
|
}
|
|
private func milliseconds(since start: CFTimeInterval) -> Double {
|
(CACurrentMediaTime() - start) * 1_000
|
}
|
|
private func percentile95(_ values: [Double]) -> Double? {
|
guard !values.isEmpty else { return nil }
|
|
let sortedValues = values.sorted()
|
let index = min(
|
sortedValues.count - 1,
|
Int(ceil(Double(sortedValues.count) * 0.95)) - 1
|
)
|
return sortedValues[index]
|
}
|
|
private func jsonNumber(_ value: Double?) -> Any {
|
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 = layerHostView.layer else { return }
|
|
rootLayer.masksToBounds = true
|
backgroundLayer.colors = [
|
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)
|
|
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.clear.cgColor
|
laneLayer.cornerRadius = 0
|
laneLayer.masksToBounds = true
|
glassLayer.addSublayer(laneLayer)
|
laneLayer.addSublayer(spaceLaneContentLayer)
|
|
shelfLayer.backgroundColor = NSColor.clear.cgColor
|
shelfLayer.cornerRadius = 16
|
shelfLayer.masksToBounds = true
|
glassLayer.addSublayer(shelfLayer)
|
shelfLayer.addSublayer(appShelfContentLayer)
|
|
waterfallLayer.backgroundColor = NSColor.clear.cgColor
|
waterfallLayer.masksToBounds = true
|
glassLayer.addSublayer(waterfallLayer)
|
waterfallLayer.addSublayer(waterfallContentLayer)
|
|
closeConfirmationLayers.containerLayer.isHidden = true
|
glassLayer.addSublayer(closeConfirmationLayers.containerLayer)
|
|
closeFeedbackLayer.isHidden = true
|
closeFeedbackLayer.opacity = 0
|
closeFeedbackLayer.cornerRadius = 11
|
closeFeedbackLayer.borderWidth = 1
|
closeFeedbackLayer.masksToBounds = false
|
closeFeedbackLayer.shadowColor = NSColor.black.cgColor
|
closeFeedbackLayer.shadowOpacity = 0.14
|
closeFeedbackLayer.shadowRadius = 16
|
closeFeedbackLayer.shadowOffset = CGSize(width: 0, height: -5)
|
closeFeedbackLayer.zPosition = 190
|
closeFeedbackTextLayer.contentsScale = backingScaleFactor
|
closeFeedbackTextLayer.font = NSFont.systemFont(ofSize: 13, weight: .medium)
|
closeFeedbackTextLayer.fontSize = 13
|
closeFeedbackTextLayer.alignmentMode = .center
|
closeFeedbackTextLayer.truncationMode = .end
|
closeFeedbackLayer.addSublayer(closeFeedbackTextLayer)
|
glassLayer.addSublayer(closeFeedbackLayer)
|
}
|
|
private func rebuildAppShelfItems() {
|
for item in appShelfItems {
|
item.containerLayer.removeFromSuperlayer()
|
}
|
|
appShelfItems = (currentViewModel?.appShelf ?? []).map { item in
|
let container = CALayer()
|
container.name = item.app.name
|
|
let background = CALayer()
|
background.cornerRadius = 10
|
background.backgroundColor = NSColor.clear.cgColor
|
background.borderColor = NSColor.clear.cgColor
|
background.borderWidth = 0
|
|
let icon = CALayer()
|
icon.contentsGravity = .resizeAspect
|
icon.contentsScale = backingScaleFactor
|
icon.contents = appIconProvider.icon(for: item.app)
|
icon.cornerRadius = 12
|
icon.masksToBounds = false
|
|
let label = makeTextLayer(
|
string: item.app.name,
|
fontSize: 11,
|
weight: .regular,
|
color: .secondaryLabelColor,
|
alignment: .center
|
)
|
|
let badge = makeTextLayer(
|
string: item.windowCount > 1 ? "\(item.windowCount)" : "",
|
fontSize: 10,
|
weight: .semibold,
|
color: .white,
|
alignment: .center
|
)
|
badge.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.90).cgColor
|
badge.cornerRadius = 8
|
badge.masksToBounds = true
|
|
let selectedIndicator = CALayer()
|
selectedIndicator.isHidden = true
|
let closeButton = makeCloseButtonLayers()
|
|
container.addSublayer(background)
|
container.addSublayer(icon)
|
container.addSublayer(label)
|
container.addSublayer(badge)
|
container.addSublayer(closeButton.containerLayer)
|
appShelfContentLayer.addSublayer(container)
|
|
return AppShelfItemLayers(
|
item: item,
|
containerLayer: container,
|
backgroundLayer: background,
|
iconLayer: icon,
|
labelLayer: label,
|
badgeLayer: badge,
|
selectedIndicatorLayer: selectedIndicator,
|
closeButton: closeButton
|
)
|
}
|
}
|
|
private func rebuildWaterfallColumns() {
|
for column in waterfallColumns {
|
column.containerLayer.removeFromSuperlayer()
|
}
|
|
let existingOffsets = waterfallColumnScrollOffsets
|
waterfallColumns = (currentViewModel?.waterfallColumns ?? []).map { column in
|
let container = CALayer()
|
container.name = column.app.name
|
container.cornerRadius = 14
|
container.masksToBounds = true
|
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.68).cgColor
|
|
let appName = makeTextLayer(
|
string: column.app.name,
|
fontSize: 13,
|
weight: .semibold,
|
color: .labelColor,
|
alignment: .center
|
)
|
let count = makeTextLayer(
|
string: "\(column.windows.count)",
|
fontSize: 11,
|
weight: .medium,
|
color: .secondaryLabelColor,
|
alignment: .right
|
)
|
|
container.addSublayer(header)
|
container.addSublayer(appName)
|
container.addSublayer(count)
|
|
let cards = column.windows.map { window in
|
let card = CALayer()
|
card.cornerRadius = 12
|
card.masksToBounds = false
|
|
let thumbnail = CALayer()
|
thumbnail.cornerRadius = 8
|
thumbnail.contentsGravity = .resizeAspectFill
|
thumbnail.contentsScale = backingScaleFactor
|
thumbnail.masksToBounds = true
|
thumbnail.contents = skeletonThumbnailProvider
|
.withOverlayTitle(window.window.title, for: window.window.app)
|
|
let titleBar = CALayer()
|
titleBar.borderWidth = 0
|
|
let title = makeTextLayer(
|
string: window.window.title.isEmpty ? "Untitled Window" : window.window.title,
|
fontSize: 12,
|
weight: .medium,
|
color: .labelColor
|
)
|
title.truncationMode = .middle
|
let meta = makeTextLayer(
|
string: waterfallMetaText(for: window),
|
fontSize: 10,
|
weight: .regular,
|
color: .secondaryLabelColor
|
)
|
let appIcon = CALayer()
|
appIcon.contentsGravity = .resizeAspect
|
appIcon.contentsScale = backingScaleFactor
|
appIcon.contents = appIconProvider.icon(for: window.window.app)
|
appIcon.cornerRadius = 5
|
appIcon.masksToBounds = true
|
|
let state = makeTextLayer(
|
string: waterfallStateText(for: window),
|
fontSize: 10,
|
weight: .medium,
|
color: .secondaryLabelColor,
|
alignment: .right
|
)
|
let shine = CAGradientLayer()
|
shine.colors = [
|
NSColor.white.withAlphaComponent(0.00).cgColor,
|
NSColor.white.withAlphaComponent(0.16).cgColor,
|
NSColor.white.withAlphaComponent(0.00).cgColor
|
]
|
shine.startPoint = CGPoint(x: 0, y: 1)
|
shine.endPoint = CGPoint(x: 1, y: 0)
|
shine.opacity = 0
|
let closeButton = makeCloseButtonLayers()
|
|
card.addSublayer(thumbnail)
|
card.addSublayer(titleBar)
|
card.addSublayer(appIcon)
|
card.addSublayer(title)
|
card.addSublayer(meta)
|
card.addSublayer(state)
|
card.addSublayer(shine)
|
card.addSublayer(closeButton.containerLayer)
|
container.addSublayer(card)
|
|
return WaterfallCardLayers(
|
item: window,
|
containerLayer: card,
|
titleBarLayer: titleBar,
|
titleLayer: title,
|
metaLayer: meta,
|
thumbnailLayer: thumbnail,
|
appIconLayer: appIcon,
|
stateLayer: state,
|
shineLayer: shine,
|
closeButton: closeButton
|
)
|
}
|
|
waterfallContentLayer.addSublayer(container)
|
|
return WaterfallColumnLayers(
|
column: column,
|
containerLayer: container,
|
headerLayer: header,
|
appNameLayer: appName,
|
countLayer: count,
|
cards: cards
|
)
|
}
|
|
let validAppGroupIndexes = Set(waterfallColumns.map(\.column.appGroupIndex))
|
waterfallColumnScrollOffsets = existingOffsets.filter { validAppGroupIndexes.contains($0.key) }
|
}
|
|
private func rebuildSpaceLaneSegments() {
|
for group in spaceLaneDisplayGroups {
|
group.containerLayer.removeFromSuperlayer()
|
}
|
|
var groups: [SpaceLaneDisplayGroupLayers] = []
|
var allSegments: [SpaceLaneSegmentLayers] = []
|
|
for display in currentViewModel?.displays ?? [] {
|
let groupContainer = CALayer()
|
groupContainer.name = display.displayLabel
|
groupContainer.cornerRadius = 14
|
groupContainer.masksToBounds = false
|
groupContainer.borderWidth = 0
|
groupContainer.borderColor = NSColor.clear.cgColor
|
groupContainer.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
groupContainer.shadowColor = NSColor.black.cgColor
|
groupContainer.shadowOpacity = 0
|
groupContainer.shadowRadius = 0
|
groupContainer.shadowOffset = .zero
|
|
let segments = display.spaces.map { space in
|
let container = CALayer()
|
container.name = space.label
|
container.cornerRadius = 10
|
container.borderWidth = 1
|
container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor
|
container.backgroundColor = spaceColor(for: space).cgColor
|
container.shadowColor = NSColor.black.cgColor
|
|
let labelLayer = makeTextLayer(
|
string: space.label,
|
fontSize: 15,
|
weight: .semibold,
|
color: .labelColor
|
)
|
let countLayer = makeTextLayer(
|
string: "\(space.windowCount)",
|
fontSize: 12,
|
weight: .medium,
|
color: .secondaryLabelColor,
|
alignment: .center
|
)
|
let appLayer = makeTextLayer(
|
string: appIdentifierText(for: space),
|
fontSize: 12,
|
weight: space.type == .fullscreen ? .medium : .regular,
|
color: space.type == .fullscreen ? .labelColor : .secondaryLabelColor,
|
alignment: space.type == .fullscreen ? .center : .left
|
)
|
let windowBlocks = space.type == .fullscreen
|
? []
|
: makeWindowBlocks(count: min(space.windowCount, 5), space: space)
|
let fullscreenMarkerLayer = makeFullscreenMarkerLayer(for: space)
|
|
container.addSublayer(labelLayer)
|
container.addSublayer(countLayer)
|
container.addSublayer(appLayer)
|
if let fullscreenMarkerLayer {
|
container.addSublayer(fullscreenMarkerLayer)
|
}
|
for block in windowBlocks {
|
container.addSublayer(block)
|
}
|
groupContainer.addSublayer(container)
|
|
return SpaceLaneSegmentLayers(
|
space: space,
|
containerLayer: container,
|
labelLayer: labelLayer,
|
countLayer: countLayer,
|
appLayer: appLayer,
|
windowBlocks: windowBlocks,
|
fullscreenMarkerLayer: fullscreenMarkerLayer
|
)
|
}
|
|
spaceLaneContentLayer.addSublayer(groupContainer)
|
groups.append(SpaceLaneDisplayGroupLayers(
|
display: display,
|
containerLayer: groupContainer,
|
segments: segments
|
))
|
allSegments.append(contentsOf: segments)
|
}
|
|
spaceLaneDisplayGroups = groups
|
spaceLaneSegments = allSegments
|
}
|
|
private func layoutLayers() {
|
CATransaction.begin()
|
CATransaction.setDisableActions(true)
|
|
backgroundLayer.frame = bounds
|
|
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 = 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 - topMargin - laneHeight,
|
width: availableContentWidth,
|
height: laneHeight
|
)
|
shelfLayer.frame = CGRect(
|
x: horizontalMargin,
|
y: laneLayer.frame.minY - RootLayoutMetrics.shelfGap - shelfHeight,
|
width: laneLayer.frame.width,
|
height: shelfHeight
|
)
|
waterfallLayer.frame = CGRect(
|
x: shelfLayer.frame.minX,
|
y: RootLayoutMetrics.bottomMargin,
|
width: shelfLayer.frame.width,
|
height: max(1, shelfLayer.frame.minY - RootLayoutMetrics.bottomMargin - RootLayoutMetrics.waterfallTopGap)
|
)
|
|
layoutSpaceLaneSegments()
|
layoutAppShelfItems()
|
layoutWaterfallColumns()
|
layoutCloseConfirmation()
|
layoutCloseFeedback()
|
|
CATransaction.commit()
|
}
|
|
private var topSafeAreaInset: CGFloat {
|
max(0, window?.screen?.safeAreaInsets.top ?? safeAreaInsets.top)
|
}
|
|
private func appShelfLayout(for availableWidth: CGFloat) -> AppShelfLayout {
|
let itemCount = appShelfItems.count
|
guard itemCount > 0 else {
|
return AppShelfLayout(rows: 0, iconSize: AppShelfMetrics.normalIconSize)
|
}
|
|
let innerWidth = max(1, availableWidth - 24)
|
let normalOneRowWidth = widthForAppShelfItems(
|
count: itemCount,
|
itemWidth: appShelfSlotWidth(forIconSize: AppShelfMetrics.normalIconSize),
|
gap: AppShelfMetrics.normalGap
|
)
|
if normalOneRowWidth <= innerWidth {
|
return AppShelfLayout(rows: 1, iconSize: AppShelfMetrics.normalIconSize)
|
}
|
|
if let compressedOneRowIconSize = compressedAppShelfIconSize(
|
fitting: itemCount,
|
availableWidth: innerWidth
|
) {
|
return AppShelfLayout(
|
rows: 1,
|
iconSize: compressedOneRowIconSize
|
)
|
}
|
|
let maxItemsPerRow = Int(ceil(Double(itemCount) / Double(AppShelfMetrics.maxRows)))
|
let twoRowIconSize = compressedAppShelfIconSize(
|
fitting: maxItemsPerRow,
|
availableWidth: innerWidth
|
) ?? AppShelfMetrics.minIconSize
|
return AppShelfLayout(
|
rows: AppShelfMetrics.maxRows,
|
iconSize: twoRowIconSize
|
)
|
}
|
|
private func compressedAppShelfIconSize(fitting itemCount: Int, availableWidth: CGFloat) -> CGFloat? {
|
let minimumWidth = widthForAppShelfItems(
|
count: itemCount,
|
itemWidth: appShelfSlotWidth(forIconSize: AppShelfMetrics.minIconSize),
|
gap: AppShelfMetrics.minGap
|
)
|
guard minimumWidth <= availableWidth else { return nil }
|
|
let rawIconSize = floor(
|
(availableWidth - CGFloat(itemCount - 1) * AppShelfMetrics.minGap) / CGFloat(itemCount)
|
- appShelfSlotPadding
|
)
|
return max(AppShelfMetrics.minIconSize, min(AppShelfMetrics.normalIconSize, rawIconSize))
|
}
|
|
private func appShelfHeight(forRows rows: Int) -> CGFloat {
|
switch rows {
|
case 0, 1:
|
return 92
|
default:
|
return 148
|
}
|
}
|
|
private var appShelfSlotPadding: CGFloat {
|
20
|
}
|
|
private func appShelfSlotWidth(forIconSize iconSize: CGFloat) -> CGFloat {
|
max(iconSize + appShelfSlotPadding, 64)
|
}
|
|
private func widthForAppShelfItems(count: Int, itemWidth: CGFloat, gap: CGFloat) -> CGFloat {
|
guard count > 0 else { return 0 }
|
return CGFloat(count) * itemWidth + CGFloat(count - 1) * gap
|
}
|
|
private func layoutSpaceLaneSegments() {
|
guard !spaceLaneDisplayGroups.isEmpty, !spaceLaneSegments.isEmpty else { return }
|
|
let groupOuterVerticalInset: CGFloat = 6
|
let groupHeight = max(1, laneLayer.bounds.height - groupOuterVerticalInset * 2)
|
let segmentHeight = max(1, groupHeight - SpaceLaneMetrics.groupVerticalPadding * 2)
|
let groupGapWidth = CGFloat(max(0, spaceLaneDisplayGroups.count - 1)) * SpaceLaneMetrics.groupGap
|
let segmentGapWidth = spaceLaneDisplayGroups.reduce(CGFloat(0)) { total, group in
|
total + CGFloat(max(0, group.segments.count - 1)) * SpaceLaneMetrics.segmentGap
|
}
|
let groupPaddingWidth = CGFloat(spaceLaneDisplayGroups.count) * SpaceLaneMetrics.groupHorizontalPadding * 2
|
let availableWidth = laneLayer.bounds.width
|
- SpaceLaneMetrics.horizontalPadding * 2
|
- groupGapWidth
|
- segmentGapWidth
|
- groupPaddingWidth
|
let segmentWidth = max(
|
SpaceLaneMetrics.segmentMinWidth,
|
min(SpaceLaneMetrics.segmentMaxWidth, availableWidth / CGFloat(spaceLaneSegments.count))
|
)
|
let groupWidths = spaceLaneDisplayGroups.map { group in
|
SpaceLaneMetrics.groupHorizontalPadding * 2
|
+ CGFloat(group.segments.count) * segmentWidth
|
+ CGFloat(max(0, group.segments.count - 1)) * SpaceLaneMetrics.segmentGap
|
}
|
let segmentGroupWidth = groupWidths.reduce(0, +) + groupGapWidth
|
spaceLaneContentWidth = max(
|
laneLayer.bounds.width,
|
SpaceLaneMetrics.horizontalPadding * 2 + segmentGroupWidth
|
)
|
spaceLaneScrollOffset = clampedSpaceLaneOffset(spaceLaneScrollOffset)
|
spaceLaneContentLayer.frame = CGRect(
|
x: -spaceLaneScrollOffset,
|
y: 0,
|
width: spaceLaneContentWidth,
|
height: laneLayer.bounds.height
|
)
|
|
var x = max(
|
SpaceLaneMetrics.horizontalPadding,
|
(spaceLaneContentWidth - segmentGroupWidth) / 2
|
)
|
|
for (index, group) in spaceLaneDisplayGroups.enumerated() {
|
let groupWidth = groupWidths[index]
|
group.containerLayer.frame = CGRect(
|
x: x,
|
y: groupOuterVerticalInset,
|
width: groupWidth,
|
height: groupHeight
|
)
|
applySpaceLaneDisplayGroupVisualState(group)
|
|
var segmentX = SpaceLaneMetrics.groupHorizontalPadding
|
for segment in group.segments {
|
segment.containerLayer.frame = CGRect(
|
x: segmentX,
|
y: SpaceLaneMetrics.groupVerticalPadding,
|
width: segmentWidth,
|
height: segmentHeight
|
)
|
applySpaceLaneVisualState(segment)
|
layoutSpaceLaneSegmentContents(segment)
|
segmentX += segmentWidth + SpaceLaneMetrics.segmentGap
|
}
|
|
x += groupWidth + SpaceLaneMetrics.groupGap
|
}
|
}
|
|
private var spaceLaneMaxScrollOffset: CGFloat {
|
max(0, spaceLaneContentWidth - laneLayer.bounds.width)
|
}
|
|
private func clampedSpaceLaneOffset(_ offset: CGFloat) -> CGFloat {
|
min(max(0, offset), spaceLaneMaxScrollOffset)
|
}
|
|
private func applySpaceLaneDisplayGroupVisualState(_ group: SpaceLaneDisplayGroupLayers) {
|
let hasActiveSegment = group.segments.contains { segment in
|
segment.space.id == activeSpaceFocusID
|
|| appHoverAssociatedSpaceIDs.contains(segment.space.id)
|
|| segment.space.id == windowHoverAssociatedSpaceID
|
}
|
let hasCurrentSegment = group.display.spaces.contains { $0.isCurrent }
|
group.containerLayer.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
group.containerLayer.borderColor = NSColor.clear.cgColor
|
group.containerLayer.borderWidth = 0
|
group.containerLayer.shadowOpacity = 0
|
group.containerLayer.shadowRadius = 0
|
group.containerLayer.shadowOffset = .zero
|
group.containerLayer.zPosition = hasActiveSegment ? 8 : hasCurrentSegment ? 4 : 0
|
group.containerLayer.shadowPath = nil
|
}
|
|
private func applySpaceLaneVisualState(_ segment: SpaceLaneSegmentLayers) {
|
let isHovered = segment.space.id == activeSpaceFocusID
|
let isFocused = segment.space.id == activeSpaceFocusID
|
let isCurrent = segment.space.isCurrent
|
let hasWindows = segment.space.windowCount > 0
|
let isAppAssociated = appHoverAssociatedSpaceIDs.contains(segment.space.id)
|
let isWindowAssociated = segment.space.id == windowHoverAssociatedSpaceID
|
let isActive = isHovered || isFocused
|
let isAssociated = (isAppAssociated || isWindowAssociated) && !isActive
|
|
segment.containerLayer.borderWidth = (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1
|
segment.containerLayer.borderColor = (isActive
|
? spaceLaneOccupiedColor.withAlphaComponent(0.62)
|
: isAssociated
|
? spaceLaneOccupiedColor.withAlphaComponent(0.50)
|
: hasWindows
|
? spaceLaneOccupiedColor.withAlphaComponent(isCurrent ? 0.54 : 0.44)
|
: isCurrent
|
? NSColor.labelColor.withAlphaComponent(0.34)
|
: NSColor.separatorColor.withAlphaComponent(0.34)
|
).cgColor
|
segment.containerLayer.backgroundColor = (isActive
|
? accentTintedWindowBackground(fraction: 0.10, alpha: 0.94)
|
: isAssociated
|
? accentTintedWindowBackground(fraction: 0.07, alpha: 0.90)
|
: spaceColor(for: segment.space)
|
).cgColor
|
segment.containerLayer.shadowOpacity = isActive ? 0.26 : isAssociated ? 0.18 : isCurrent ? 0.14 : 0
|
segment.containerLayer.shadowRadius = isActive ? 24 : isAssociated ? 18 : isCurrent ? 12 : 0
|
segment.containerLayer.shadowOffset = CGSize(
|
width: 0,
|
height: isActive ? -6 : isAssociated ? -4 : isCurrent ? -2.5 : 0
|
)
|
segment.containerLayer.zPosition = isActive ? 30 : isAssociated ? 18 : isCurrent ? 10 : 0
|
segment.containerLayer.transform = (isActive || isAssociated)
|
? CATransform3DMakeScale(isActive ? 1.03 : 1.015, isActive ? 1.03 : 1.015, 1)
|
: CATransform3DIdentity
|
segment.containerLayer.shadowPath = CGPath(
|
roundedRect: segment.containerLayer.bounds,
|
cornerWidth: segment.containerLayer.cornerRadius,
|
cornerHeight: segment.containerLayer.cornerRadius,
|
transform: nil
|
)
|
}
|
|
private func shouldScrollSpaceLane(for event: NSEvent) -> Bool {
|
guard let window else { return false }
|
|
let locationInView = convert(event.locationInWindow, from: nil)
|
let laneFrameInView = laneLayer.frame.offsetBy(dx: glassLayer.frame.minX, dy: glassLayer.frame.minY)
|
return window == self.window && laneFrameInView.contains(locationInView)
|
}
|
|
private func shouldScrollAppShelf(for event: NSEvent) -> Bool {
|
guard let window else { return false }
|
|
let locationInView = convert(event.locationInWindow, from: nil)
|
let shelfFrameInView = shelfLayer.frame.offsetBy(dx: glassLayer.frame.minX, dy: glassLayer.frame.minY)
|
return window == self.window && shelfFrameInView.contains(locationInView)
|
}
|
|
private func shouldScrollWaterfall(for event: NSEvent) -> Bool {
|
guard let window else { return false }
|
|
let locationInView = convert(event.locationInWindow, from: nil)
|
let waterfallFrameInView = waterfallLayer.frame.offsetBy(dx: glassLayer.frame.minX, dy: glassLayer.frame.minY)
|
return window == self.window && waterfallFrameInView.contains(locationInView)
|
}
|
|
private func waterfallColumn(at windowLocation: CGPoint) -> WaterfallColumnLayers? {
|
let locationInView = convert(windowLocation, from: nil)
|
let locationInWaterfallContent = CGPoint(
|
x: locationInView.x - glassLayer.frame.minX - waterfallLayer.frame.minX - waterfallContentLayer.frame.minX,
|
y: locationInView.y - glassLayer.frame.minY - waterfallLayer.frame.minY - waterfallContentLayer.frame.minY
|
)
|
|
return waterfallColumns.first { column in
|
column.containerLayer.frame.contains(locationInWaterfallContent)
|
}
|
}
|
|
private func spaceLaneSegment(at windowLocation: CGPoint) -> SpaceLaneSegmentLayers? {
|
let locationInView = convert(windowLocation, from: nil)
|
|
// 严格 band 检查:只有鼠标在 lane 的垂直 band 内才考虑是 lane hover。
|
// 防止“完全空的地方”(y 不在 lane 高度范围内)误 hit 到 segment frame,
|
// 导致 hoveredSpaceLaneID 粘住、activeSpaceFocusID 非 nil、窗口 spaceFocused 高亮。
|
let laneMinYInView = glassLayer.frame.minY + laneLayer.frame.minY
|
let laneMaxYInView = laneMinYInView + laneLayer.frame.height
|
if locationInView.y < laneMinYInView || locationInView.y > laneMaxYInView {
|
return nil
|
}
|
|
let locationInSpaceLaneContent = CGPoint(
|
x: locationInView.x - glassLayer.frame.minX - laneLayer.frame.minX - spaceLaneContentLayer.frame.minX,
|
y: locationInView.y - glassLayer.frame.minY - laneLayer.frame.minY - spaceLaneContentLayer.frame.minY
|
)
|
|
for group in spaceLaneDisplayGroups where group.containerLayer.frame.contains(locationInSpaceLaneContent) {
|
let locationInGroup = CGPoint(
|
x: locationInSpaceLaneContent.x - group.containerLayer.frame.minX,
|
y: locationInSpaceLaneContent.y - group.containerLayer.frame.minY
|
)
|
if let segment = group.segments.first(where: { $0.containerLayer.frame.contains(locationInGroup) }) {
|
return segment
|
}
|
}
|
|
return nil
|
}
|
|
private func spaceLaneSegment(spaceID: UInt64) -> SpaceLaneSegmentLayers? {
|
spaceLaneSegments.first { $0.space.id == spaceID }
|
}
|
|
private func appShelfItem(at windowLocation: CGPoint) -> AppShelfItemLayers? {
|
let locationInView = convert(windowLocation, from: nil)
|
let locationInShelfContent = CGPoint(
|
x: locationInView.x - glassLayer.frame.minX - shelfLayer.frame.minX - appShelfContentLayer.frame.minX,
|
y: locationInView.y - glassLayer.frame.minY - shelfLayer.frame.minY - appShelfContentLayer.frame.minY
|
)
|
|
return appShelfItems.first { item in
|
item.containerLayer.frame.contains(locationInShelfContent)
|
}
|
}
|
|
private func waterfallCard(at windowLocation: CGPoint) -> WaterfallCardLayers? {
|
let locationInView = convert(windowLocation, from: nil)
|
let locationInWaterfallContent = CGPoint(
|
x: locationInView.x - glassLayer.frame.minX - waterfallLayer.frame.minX - waterfallContentLayer.frame.minX,
|
y: locationInView.y - glassLayer.frame.minY - waterfallLayer.frame.minY - waterfallContentLayer.frame.minY
|
)
|
|
for column in waterfallColumns where column.containerLayer.frame.contains(locationInWaterfallContent) {
|
let locationInColumn = CGPoint(
|
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) }) {
|
return card
|
}
|
}
|
|
return nil
|
}
|
|
private func waterfallCard(appGroupIndex: Int, windowIndex: Int) -> WaterfallCardLayers? {
|
waterfallColumns
|
.first(where: { $0.column.appGroupIndex == appGroupIndex })?
|
.cards
|
.first(where: { $0.item.windowIndex == windowIndex })
|
}
|
|
private func layoutSpaceLaneSegmentContents(_ segment: SpaceLaneSegmentLayers) {
|
let bounds = segment.containerLayer.bounds
|
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)
|
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
|
)
|
} else {
|
segment.appLayer.frame = CGRect(x: 10, y: 9, width: max(1, bounds.width - 20), height: 17)
|
}
|
if let fullscreenMarkerLayer = segment.fullscreenMarkerLayer {
|
layoutFullscreenMarkerLayer(fullscreenMarkerLayer, in: bounds)
|
}
|
|
let blockGap: CGFloat = 5
|
let blockWidth = max(12, min(22, (bounds.width - 20 - CGFloat(max(0, segment.windowBlocks.count - 1)) * blockGap) / CGFloat(max(1, segment.windowBlocks.count))))
|
let blockHeight: CGFloat = 14
|
var x: CGFloat = 10
|
|
for block in segment.windowBlocks {
|
block.frame = CGRect(x: x, y: 31, width: blockWidth, height: blockHeight)
|
x += blockWidth + blockGap
|
}
|
}
|
|
private func layoutAppShelfItems() {
|
guard !appShelfItems.isEmpty else {
|
appShelfContentWidth = shelfLayer.bounds.width
|
appShelfScrollOffset = 0
|
appShelfContentLayer.frame = shelfLayer.bounds
|
return
|
}
|
|
let rows = appShelfRows()
|
let iconSize = appShelfLayout.iconSize
|
let gap = appShelfGap(forIconSize: iconSize)
|
let itemWidth = appShelfSlotWidth(forIconSize: iconSize)
|
let rowHeight: CGFloat = AppShelfMetrics.selectedIconSize + 18
|
let rowGap: CGFloat = appShelfLayout.rows > 1 ? 8 : 0
|
let innerWidth = max(1, shelfLayer.bounds.width - 24)
|
let maxRowWidth = rows
|
.map { widthForAppShelfItems(count: $0.count, itemWidth: itemWidth, gap: gap) }
|
.max() ?? innerWidth
|
appShelfContentWidth = max(shelfLayer.bounds.width, maxRowWidth + 24)
|
appShelfScrollOffset = clampedAppShelfOffset(appShelfScrollOffset)
|
appShelfContentLayer.frame = CGRect(
|
x: -appShelfScrollOffset,
|
y: 0,
|
width: appShelfContentWidth,
|
height: shelfLayer.bounds.height
|
)
|
|
let totalRowsHeight = CGFloat(rows.count) * rowHeight + CGFloat(max(0, rows.count - 1)) * rowGap
|
var y = shelfLayer.bounds.midY + totalRowsHeight / 2 - rowHeight
|
|
for row in rows {
|
let rowWidth = widthForAppShelfItems(count: row.count, itemWidth: itemWidth, gap: gap)
|
var x = max(12, (appShelfContentWidth - rowWidth) / 2)
|
|
for item in row {
|
item.containerLayer.frame = CGRect(x: x, y: y, width: itemWidth, height: rowHeight)
|
layoutAppShelfItemContents(item, iconSize: iconSize)
|
x += itemWidth + gap
|
}
|
|
y -= rowHeight + rowGap
|
}
|
}
|
|
private func layoutAppShelfItemContents(_ item: AppShelfItemLayers, iconSize: CGFloat) {
|
let bounds = item.containerLayer.bounds
|
let isSelected = item.item.appGroupIndex == effectiveSelection?.appGroupIndex
|
let isHovered = item.item.appGroupIndex == effectiveHoveredAppGroupIndex
|
let showsSelectedVisual = shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered)
|
let isSpaceFocused = spaceFocusWindowCount(for: item.item.appGroupIndex) > 0
|
let visualIconSize = appShelfVisualIconSize(
|
forBaseIconSize: iconSize,
|
selected: showsSelectedVisual,
|
hovered: isHovered,
|
spaceFocused: isSpaceFocused
|
)
|
let backgroundPadding: CGFloat = (showsSelectedVisual || isHovered)
|
? 4
|
: isSpaceFocused
|
? 6
|
: 2
|
let iconX = (bounds.width - visualIconSize) / 2
|
let iconY = bounds.height - visualIconSize - 2
|
let badgeText = appShelfBadgeText(for: item)
|
let badgeWidth = max(18, CGFloat(badgeText.count) * 7 + 8)
|
|
item.containerLayer.zPosition = appShelfZPosition(
|
selected: showsSelectedVisual,
|
hovered: isHovered,
|
spaceFocused: isSpaceFocused
|
)
|
item.backgroundLayer.backgroundColor = appShelfBackgroundColor(
|
selected: showsSelectedVisual,
|
hovered: isHovered,
|
spaceFocused: isSpaceFocused
|
).cgColor
|
item.backgroundLayer.borderColor = (showsSelectedVisual
|
? NSColor.controlAccentColor.withAlphaComponent(0.40)
|
: isHovered
|
? NSColor.controlAccentColor.withAlphaComponent(0.36)
|
: NSColor.clear
|
).cgColor
|
item.backgroundLayer.borderWidth = (showsSelectedVisual || isHovered) ? 1 : 0
|
item.backgroundLayer.shadowColor = NSColor.black.cgColor
|
item.backgroundLayer.shadowOpacity = showsSelectedVisual ? 0.11 : isHovered ? 0.07 : isSpaceFocused ? 0.18 : 0
|
item.backgroundLayer.shadowRadius = showsSelectedVisual ? 10 : isHovered ? 8 : isSpaceFocused ? 18 : 0
|
item.backgroundLayer.shadowOffset = CGSize(width: 0, height: isSpaceFocused ? -4 : -2)
|
item.backgroundLayer.frame = CGRect(
|
x: iconX - backgroundPadding,
|
y: iconY - backgroundPadding,
|
width: visualIconSize + backgroundPadding * 2,
|
height: visualIconSize + backgroundPadding * 2
|
)
|
item.backgroundLayer.shadowPath = (showsSelectedVisual || isHovered || isSpaceFocused)
|
? CGPath(
|
roundedRect: item.backgroundLayer.bounds,
|
cornerWidth: item.backgroundLayer.cornerRadius,
|
cornerHeight: item.backgroundLayer.cornerRadius,
|
transform: nil
|
)
|
: nil
|
item.iconLayer.frame = CGRect(x: iconX, y: iconY, width: visualIconSize, height: visualIconSize)
|
item.labelLayer.contentsScale = backingScaleFactor
|
item.labelLayer.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 14)
|
item.badgeLayer.string = badgeText
|
item.badgeLayer.isHidden = badgeText.isEmpty
|
item.badgeLayer.backgroundColor = (isSpaceFocused
|
? NSColor.controlAccentColor.withAlphaComponent(0.88)
|
: NSColor.controlAccentColor.withAlphaComponent(0.90)
|
).cgColor
|
item.badgeLayer.frame = CGRect(
|
x: iconX - 3,
|
y: iconY - 2,
|
width: badgeWidth,
|
height: 16
|
)
|
layoutCloseButton(
|
item.closeButton,
|
target: .app(appGroupIndex: item.item.appGroupIndex),
|
visible: isHovered && canClose(app: item.item.app),
|
anchorTopRight: CGPoint(
|
x: min(iconX + visualIconSize - 6, bounds.width - 12),
|
y: min(iconY + visualIconSize - 6, bounds.height - 12)
|
)
|
)
|
item.selectedIndicatorLayer.frame = .zero
|
item.selectedIndicatorLayer.backgroundColor = NSColor.clear.cgColor
|
}
|
|
private func appShelfRows() -> [[AppShelfItemLayers]] {
|
guard appShelfLayout.rows > 1 else { return [appShelfItems] }
|
|
let firstRowCount = Int(ceil(Double(appShelfItems.count) / Double(AppShelfMetrics.maxRows)))
|
return [
|
Array(appShelfItems.prefix(firstRowCount)),
|
Array(appShelfItems.dropFirst(firstRowCount))
|
].filter { !$0.isEmpty }
|
}
|
|
private func appShelfGap(forIconSize iconSize: CGFloat) -> CGFloat {
|
if iconSize >= AppShelfMetrics.normalIconSize {
|
return AppShelfMetrics.normalGap
|
}
|
|
return AppShelfMetrics.minGap
|
}
|
|
private var effectiveHoveredAppGroupIndex: Int? {
|
debugHoveredAppGroupIndex ?? hoveredAppGroupIndex
|
}
|
|
private var activeSpaceFocusID: UInt64? {
|
guard case .space(let spaceID) = hoverTarget else { return nil }
|
|
if hoverTargetSource == .debug {
|
return spaceID
|
}
|
|
guard let window else { return nil }
|
let currentMouseLocation = window.mouseLocationOutsideOfEventStream
|
guard let segment = spaceLaneSegment(at: currentMouseLocation),
|
segment.space.id == spaceID
|
else {
|
return nil
|
}
|
return spaceID
|
}
|
|
private var appHoverAssociatedSpaceIDs: Set<UInt64> {
|
guard case .app(let appGroupIndex) = hoverTarget else { return [] }
|
return Set(
|
waterfallColumns
|
.first { $0.column.appGroupIndex == appGroupIndex }?
|
.cards
|
.compactMap(\.item.primarySpaceID) ?? []
|
)
|
}
|
|
private var appHoverAssociatedSpaceLabels: [String] {
|
guard !appHoverAssociatedSpaceIDs.isEmpty else { return [] }
|
return currentViewModel?.displays
|
.flatMap(\.spaces)
|
.filter { appHoverAssociatedSpaceIDs.contains($0.id) }
|
.map(\.label)
|
.sorted() ?? []
|
}
|
|
private var windowHoverAssociatedSpaceID: UInt64? {
|
guard case .window(_, _, let spaceID) = hoverTarget else { return nil }
|
return spaceID
|
}
|
|
private var isSpaceAssociationActive: Bool {
|
activeSpaceFocusID != nil
|
}
|
|
private func shouldShowSelectedVisual(isSelected: Bool, isHovered: Bool) -> Bool {
|
isSelected && isHovered && !isSpaceAssociationActive
|
}
|
|
private func spaceFocusedAppGroupIndexes() -> [Int] {
|
spaceFocusedAppGroupIndexes(for: activeSpaceFocusID)
|
}
|
|
private func spaceFocusedAppGroupIndexes(for spaceID: UInt64?) -> [Int] {
|
guard let spaceID else { return [] }
|
|
return waterfallColumns
|
.filter { column in
|
column.cards.contains { $0.item.primarySpaceID == spaceID }
|
}
|
.map(\.column.appGroupIndex)
|
.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 }
|
|
return waterfallColumns
|
.first(where: { $0.column.appGroupIndex == appGroupIndex })?
|
.cards
|
.filter { $0.item.primarySpaceID == activeSpaceFocusID }
|
.count ?? 0
|
}
|
|
private func spaceFocusDirection(for appGroupIndex: Int) -> String {
|
guard spaceFocusWindowCount(for: appGroupIndex) > 0,
|
let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex })
|
else {
|
return "none"
|
}
|
|
let visibleMinX = waterfallScrollOffset
|
let visibleMaxX = waterfallScrollOffset + waterfallLayer.bounds.width
|
if column.containerLayer.frame.maxX < visibleMinX + WaterfallMetrics.gap {
|
return "left"
|
}
|
if column.containerLayer.frame.minX > visibleMaxX - WaterfallMetrics.gap {
|
return "right"
|
}
|
return "visible"
|
}
|
|
private func appShelfVisualIconSize(
|
forBaseIconSize iconSize: CGFloat,
|
selected: Bool,
|
hovered: Bool,
|
spaceFocused: Bool
|
) -> CGFloat {
|
if selected || hovered {
|
return AppShelfMetrics.selectedIconSize
|
}
|
if spaceFocused {
|
return min(AppShelfMetrics.selectedIconSize, iconSize + 8)
|
}
|
return iconSize
|
}
|
|
private func appShelfZPosition(selected: Bool, hovered: Bool, spaceFocused: Bool) -> CGFloat {
|
switch (selected, hovered, spaceFocused) {
|
case (true, true, _):
|
return 30
|
case (true, false, _):
|
return 24
|
case (false, true, _):
|
return 18
|
case (false, false, true):
|
return 12
|
case (false, false, false):
|
return 0
|
}
|
}
|
|
private func appShelfBackgroundColor(selected: Bool, hovered: Bool, spaceFocused: Bool) -> NSColor {
|
if selected {
|
return NSColor.controlAccentColor.withAlphaComponent(0.14)
|
}
|
if hovered {
|
return NSColor.controlAccentColor.withAlphaComponent(0.13)
|
}
|
if spaceFocused {
|
return NSColor.controlAccentColor.withAlphaComponent(0.14)
|
}
|
return .clear
|
}
|
|
private func appShelfBadgeText(for item: AppShelfItemLayers) -> String {
|
if let _ = activeSpaceFocusID {
|
let count = spaceFocusWindowCount(for: item.item.appGroupIndex)
|
guard count > 0 else { return "" }
|
|
let countText = count > 9 ? "9+" : "\(count)"
|
switch spaceFocusDirection(for: item.item.appGroupIndex) {
|
case "left":
|
return "<\(countText)"
|
case "right":
|
return "\(countText)>"
|
default:
|
return countText
|
}
|
}
|
|
return item.item.windowCount > 1 ? "\(item.item.windowCount)" : ""
|
}
|
|
private var appShelfMaxScrollOffset: CGFloat {
|
max(0, appShelfContentWidth - shelfLayer.bounds.width)
|
}
|
|
private func clampedAppShelfOffset(_ offset: CGFloat) -> CGFloat {
|
min(max(0, offset), appShelfMaxScrollOffset)
|
}
|
|
private func layoutWaterfallColumns() {
|
guard !waterfallColumns.isEmpty else {
|
waterfallContentWidth = waterfallLayer.bounds.width
|
waterfallScrollOffset = 0
|
waterfallContentLayer.frame = waterfallLayer.bounds
|
return
|
}
|
|
let columnWidth = waterfallColumnWidth()
|
let totalWidth = WaterfallMetrics.contentHorizontalPadding * 2
|
+ CGFloat(waterfallColumns.count) * columnWidth
|
+ CGFloat(max(0, waterfallColumns.count - 1)) * WaterfallMetrics.gap
|
waterfallContentWidth = max(waterfallLayer.bounds.width, totalWidth)
|
waterfallScrollOffset = clampedWaterfallAlignmentOffset(waterfallScrollOffset)
|
waterfallContentLayer.frame = CGRect(
|
x: -waterfallScrollOffset,
|
y: 0,
|
width: waterfallContentWidth,
|
height: waterfallLayer.bounds.height
|
)
|
|
var x = WaterfallMetrics.contentHorizontalPadding
|
for column in waterfallColumns {
|
column.containerLayer.frame = CGRect(
|
x: x,
|
y: 0,
|
width: columnWidth,
|
height: waterfallLayer.bounds.height
|
)
|
layoutWaterfallColumn(column)
|
x += columnWidth + WaterfallMetrics.gap
|
}
|
}
|
|
private func layoutWaterfallColumn(_ column: WaterfallColumnLayers) {
|
let bounds = column.containerLayer.bounds
|
let isSpaceFocused = spaceFocusWindowCount(for: column.column.appGroupIndex) > 0
|
let isHovered = column.column.appGroupIndex == effectiveHoveredAppGroupIndex
|
column.containerLayer.borderColor = (isSpaceFocused
|
? NSColor.separatorColor.withAlphaComponent(0.38)
|
: NSColor.separatorColor.withAlphaComponent(0.24)
|
).cgColor
|
column.containerLayer.borderWidth = 1
|
column.headerLayer.frame = CGRect(
|
x: 0,
|
y: bounds.height - WaterfallMetrics.headerHeight,
|
width: bounds.width,
|
height: WaterfallMetrics.headerHeight
|
)
|
column.headerLayer.backgroundColor = (isSpaceFocused
|
? NSColor.systemFill.withAlphaComponent(0.10)
|
: NSColor.controlBackgroundColor.withAlphaComponent(0.52)
|
).cgColor
|
column.appNameLayer.frame = CGRect(
|
x: WaterfallMetrics.columnHeaderTextInset,
|
y: bounds.height - 24,
|
width: max(1, bounds.width - WaterfallMetrics.columnHeaderTextInset * 2),
|
height: 16
|
)
|
column.appNameLayer.foregroundColor = (isHovered
|
? spaceLaneOccupiedColor
|
: NSColor.labelColor
|
).cgColor
|
column.countLayer.frame = CGRect(
|
x: bounds.width - 48,
|
y: bounds.height - 24,
|
width: 36,
|
height: 16
|
)
|
|
let appGroupIndex = column.column.appGroupIndex
|
let verticalOffset = clampedWaterfallColumnOffset(
|
waterfallColumnScrollOffsets[appGroupIndex] ?? 0,
|
appGroupIndex: appGroupIndex
|
)
|
waterfallColumnScrollOffsets[appGroupIndex] = verticalOffset
|
|
let cardWidth = max(1, bounds.width - WaterfallMetrics.columnHorizontalPadding * 2)
|
let firstCardTop = bounds.height
|
- WaterfallMetrics.headerHeight
|
- WaterfallMetrics.columnVerticalPadding
|
+ verticalOffset
|
|
for card in column.cards {
|
let cardIndex = card.item.windowIndex
|
let y = firstCardTop
|
- WaterfallMetrics.cardHeight
|
- CGFloat(cardIndex) * (WaterfallMetrics.cardHeight + WaterfallMetrics.cardGap)
|
card.containerLayer.frame = CGRect(
|
x: WaterfallMetrics.columnHorizontalPadding,
|
y: y,
|
width: cardWidth,
|
height: WaterfallMetrics.cardHeight
|
)
|
layoutWaterfallCard(card)
|
}
|
}
|
|
private func layoutWaterfallCard(_ card: WaterfallCardLayers) {
|
let bounds = card.containerLayer.bounds
|
let isSelected = card.item.window.id == effectiveSelection?.windowID
|
let isHovered = card.item.window.id == hoveredWindowID
|
applyWaterfallCardVisualState(
|
card,
|
selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered),
|
hovered: isHovered
|
)
|
|
let outerInset: CGFloat = 0
|
let titleBarHeight: CGFloat = 26
|
let titleGap: CGFloat = 6
|
let titleBarFrame = CGRect(
|
x: outerInset,
|
y: bounds.height - outerInset - titleBarHeight,
|
width: max(1, bounds.width - outerInset * 2),
|
height: titleBarHeight
|
)
|
let thumbnailFrame = CGRect(
|
x: outerInset,
|
y: outerInset,
|
width: max(1, bounds.width - outerInset * 2),
|
height: max(1, titleBarFrame.minY - outerInset - titleGap)
|
)
|
|
card.thumbnailLayer.frame = CGRect(
|
x: thumbnailFrame.minX,
|
y: thumbnailFrame.minY,
|
width: thumbnailFrame.width,
|
height: thumbnailFrame.height
|
)
|
card.titleBarLayer.frame = titleBarFrame
|
card.appIconLayer.frame = CGRect(x: titleBarFrame.minX + 7, y: titleBarFrame.minY + 5, width: 16, height: 16)
|
card.titleLayer.frame = CGRect(
|
x: titleBarFrame.minX + 29,
|
y: titleBarFrame.minY + 6,
|
width: max(1, titleBarFrame.width - 68),
|
height: 16
|
)
|
card.metaLayer.frame = CGRect(
|
x: titleBarFrame.maxX - 38,
|
y: titleBarFrame.minY + 6,
|
width: 30,
|
height: 14
|
)
|
card.stateLayer.frame = CGRect(
|
x: thumbnailFrame.maxX - 86,
|
y: thumbnailFrame.minY + 6,
|
width: 78,
|
height: 14
|
)
|
layoutCloseButton(
|
card.closeButton,
|
target: .window(
|
appGroupIndex: card.item.appGroupIndex,
|
windowIndex: card.item.windowIndex,
|
windowID: card.item.window.id
|
),
|
visible: isHovered,
|
anchorTopRight: CGPoint(x: bounds.maxX - 12, y: bounds.maxY - 12)
|
)
|
card.shineLayer.frame = CGRect(
|
x: -bounds.width * 0.18,
|
y: 0,
|
width: max(36, min(96, bounds.width * 0.24)),
|
height: bounds.height
|
)
|
}
|
|
private func applyWaterfallCardVisualState(_ card: WaterfallCardLayers, selected: Bool, hovered: Bool) {
|
let isSpaceFocused = isWaterfallCardSpaceFocused(card)
|
card.containerLayer.backgroundColor = (selected
|
? NSColor.windowBackgroundColor.withAlphaComponent(0.94)
|
: hovered
|
? accentTintedWindowBackground(fraction: 0.09, alpha: 0.95)
|
: isSpaceFocused
|
? accentTintedWindowBackground(fraction: 0.10, alpha: 0.94)
|
: NSColor.windowBackgroundColor.withAlphaComponent(0.82)
|
).cgColor
|
card.containerLayer.borderColor = (selected
|
? NSColor.controlAccentColor.withAlphaComponent(0.52)
|
: hovered
|
? NSColor.controlAccentColor.withAlphaComponent(0.44)
|
: isSpaceFocused
|
? NSColor.controlAccentColor.withAlphaComponent(0.42)
|
: NSColor.separatorColor.withAlphaComponent(0.28)
|
).cgColor
|
card.containerLayer.borderWidth = (selected || hovered || 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.shadowOffset = selected
|
? CGSize(width: 0, height: -6)
|
: hovered
|
? CGSize(width: 0, height: -2.5)
|
: isSpaceFocused
|
? CGSize(width: 0, height: -5)
|
: .zero
|
card.containerLayer.zPosition = selected ? 20 : hovered ? 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)
|
? CGPath(
|
roundedRect: card.containerLayer.bounds,
|
cornerWidth: card.containerLayer.cornerRadius,
|
cornerHeight: card.containerLayer.cornerRadius,
|
transform: nil
|
)
|
: nil
|
card.titleBarLayer.backgroundColor = NSColor.clear.cgColor
|
card.titleBarLayer.borderColor = NSColor.clear.cgColor
|
card.titleBarLayer.borderWidth = 0
|
card.shineLayer.opacity = selected ? 1 : 0
|
}
|
|
private func layoutCloseButton(
|
_ button: CloseButtonLayers,
|
target: CloseTarget,
|
visible: Bool,
|
anchorTopRight: CGPoint
|
) {
|
let isButtonHovered = hoveredCloseTarget == target
|
let size: CGFloat = isButtonHovered ? 24 : 18
|
let buttonFill = isButtonHovered
|
? NSColor.systemRed.withAlphaComponent(0.94)
|
: NSColor.white.withAlphaComponent(0.34)
|
let buttonStroke = isButtonHovered
|
? NSColor.white.withAlphaComponent(0.76)
|
: NSColor.white.withAlphaComponent(0.46)
|
let glyphStroke = isButtonHovered
|
? NSColor.white
|
: NSColor.white.withAlphaComponent(0.88)
|
|
button.containerLayer.frame = CGRect(
|
x: anchorTopRight.x - size / 2,
|
y: anchorTopRight.y - size / 2,
|
width: size,
|
height: size
|
)
|
button.containerLayer.cornerRadius = size / 2
|
button.containerLayer.backgroundColor = buttonFill.cgColor
|
button.containerLayer.borderColor = buttonStroke.cgColor
|
button.containerLayer.opacity = visible ? 1 : 0
|
button.containerLayer.zPosition = isButtonHovered ? 80 : 60
|
button.containerLayer.shadowOpacity = visible ? (isButtonHovered ? 0.22 : 0.04) : 0
|
button.containerLayer.shadowRadius = isButtonHovered ? 9 : 5
|
button.glyphLayer.frame = CGRect(x: 0, y: 0, width: size, height: size)
|
button.glyphLayer.lineWidth = isButtonHovered ? 2.1 : 1.45
|
button.glyphLayer.strokeColor = glyphStroke.cgColor
|
button.glyphLayer.path = closeGlyphPath(size: size, hovered: isButtonHovered)
|
}
|
|
private func closeGlyphPath(size: CGFloat, hovered: Bool) -> CGPath {
|
let inset: CGFloat = hovered ? 7.2 : 5.6
|
let path = CGMutablePath()
|
path.move(to: CGPoint(x: inset, y: inset))
|
path.addLine(to: CGPoint(x: size - inset, y: size - inset))
|
path.move(to: CGPoint(x: size - inset, y: inset))
|
path.addLine(to: CGPoint(x: inset, y: size - inset))
|
return path
|
}
|
|
private func canClose(app: AlignerApp) -> Bool {
|
guard let processIdentifier = app.processIdentifier else { return false }
|
return processIdentifier != getpid()
|
}
|
|
private func layoutCloseConfirmation() {
|
guard let pendingCloseTarget,
|
let request = closeRequest(for: pendingCloseTarget),
|
let anchor = closeConfirmationAnchor(for: pendingCloseTarget)
|
else {
|
closeConfirmationLayers.containerLayer.isHidden = true
|
closeConfirmationArrowEdge = nil
|
return
|
}
|
|
let contentWidth: CGFloat = 374
|
let height: CGFloat = 184
|
let arrowWidth: CGFloat = 14
|
let edgeMargin: CGFloat = 16
|
let anchorGap: CGFloat = 12
|
let width = contentWidth + arrowWidth
|
let availableRight = glassLayer.bounds.maxX - anchor.x - edgeMargin
|
let availableLeft = anchor.x - glassLayer.bounds.minX - edgeMargin
|
let arrowEdge: CloseConfirmationArrowEdge = availableRight >= width || availableRight >= availableLeft
|
? .left
|
: .right
|
let preferredX = arrowEdge == .left
|
? anchor.x + anchorGap
|
: anchor.x - width - anchorGap
|
let x = min(
|
max(edgeMargin, preferredX),
|
max(edgeMargin, glassLayer.bounds.width - width - edgeMargin)
|
)
|
let y = min(
|
max(edgeMargin, anchor.y - height + 64),
|
max(edgeMargin, glassLayer.bounds.height - height - edgeMargin)
|
)
|
let localArrowY = min(max(anchor.y - y, 38), height - 38)
|
closeConfirmationArrowEdge = arrowEdge
|
|
let layers = closeConfirmationLayers
|
layers.containerLayer.isHidden = false
|
layers.containerLayer.frame = CGRect(x: x, y: y, width: width, height: height)
|
let contentX = arrowEdge == .left ? arrowWidth : 0
|
let contentFrame = CGRect(x: contentX, y: 0, width: contentWidth, height: height)
|
let bubblePath = closeConfirmationBubblePath(
|
bounds: layers.containerLayer.bounds,
|
contentFrame: contentFrame,
|
arrowEdge: arrowEdge,
|
arrowY: localArrowY
|
)
|
layers.backgroundLayer.frame = layers.containerLayer.bounds
|
layers.backgroundLayer.path = bubblePath
|
layers.containerLayer.shadowPath = bubblePath
|
layers.titleLayer.string = confirmationTitle(for: request)
|
layers.messageLayer.string = confirmationMessage(for: request)
|
layers.titleLayer.frame = CGRect(x: contentFrame.minX + 22, y: height - 48, width: contentFrame.width - 44, height: 22)
|
layers.messageLayer.frame = CGRect(x: contentFrame.minX + 22, y: height - 78, width: contentFrame.width - 44, height: 21)
|
|
layers.checkboxLayer.frame = CGRect(x: contentFrame.minX + 22, y: 80, width: 18, height: 18)
|
layers.checkboxLayer.backgroundColor = (pendingCloseOptOut
|
? NSColor.controlAccentColor.withAlphaComponent(0.92)
|
: NSColor.clear
|
).cgColor
|
layers.checkboxLayer.borderColor = (pendingCloseOptOut
|
? NSColor.controlAccentColor.withAlphaComponent(0.95)
|
: NSColor.separatorColor.withAlphaComponent(0.48)
|
).cgColor
|
layers.checkboxMarkLayer.string = pendingCloseOptOut ? "✓" : ""
|
layers.checkboxMarkLayer.frame = CGRect(x: 0, y: 1, width: 18, height: 16)
|
layers.checkboxLabelLayer.frame = CGRect(x: contentFrame.minX + 48, y: 80, width: contentFrame.width - 70, height: 19)
|
|
layers.cancelButtonLayer.frame = CGRect(x: contentFrame.maxX - 164, y: 22, width: 74, height: 32)
|
layers.cancelButtonTextLayer.frame = layers.cancelButtonLayer.bounds.insetBy(dx: 0, dy: 7)
|
layers.confirmButtonLayer.frame = CGRect(x: contentFrame.maxX - 78, y: 22, width: 56, height: 32)
|
layers.confirmButtonTextLayer.frame = layers.confirmButtonLayer.bounds.insetBy(dx: 0, dy: 7)
|
}
|
|
private func setCloseFeedback(_ feedback: CloseFeedback, autoHideAfter delay: TimeInterval?) {
|
closeFeedbackHideTask?.cancel()
|
closeFeedback = feedback
|
closeFeedbackLayer.isHidden = false
|
closeFeedbackLayer.opacity = 1
|
closeFeedbackTextLayer.string = feedback.message
|
switch feedback.kind {
|
case .progress:
|
closeFeedbackLayer.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.94).cgColor
|
closeFeedbackLayer.borderColor = NSColor.separatorColor.withAlphaComponent(0.28).cgColor
|
closeFeedbackTextLayer.foregroundColor = NSColor.secondaryLabelColor.withAlphaComponent(0.96).cgColor
|
case .failure:
|
closeFeedbackLayer.backgroundColor = NSColor.systemRed.withAlphaComponent(0.10).cgColor
|
closeFeedbackLayer.borderColor = NSColor.systemRed.withAlphaComponent(0.32).cgColor
|
closeFeedbackTextLayer.foregroundColor = NSColor.systemRed.withAlphaComponent(0.95).cgColor
|
}
|
needsLayout = true
|
|
guard let delay else {
|
closeFeedbackHideTask = nil
|
return
|
}
|
|
closeFeedbackHideTask = Task { @MainActor [weak self] in
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
guard !Task.isCancelled,
|
let self,
|
self.closeFeedback == feedback
|
else {
|
return
|
}
|
self.clearCloseFeedback()
|
}
|
}
|
|
private func layoutCloseFeedback() {
|
guard let feedback = closeFeedback else {
|
closeFeedbackLayer.isHidden = true
|
closeFeedbackLayer.opacity = 0
|
return
|
}
|
|
closeFeedbackLayer.isHidden = false
|
closeFeedbackLayer.opacity = 1
|
let font = NSFont.systemFont(
|
ofSize: 13,
|
weight: feedback.kind == .failure ? .medium : .regular
|
)
|
closeFeedbackTextLayer.font = font
|
closeFeedbackTextLayer.fontSize = 13
|
let textWidth = (feedback.message as NSString).size(
|
withAttributes: [.font: font]
|
).width
|
let maxWidth = max(180, glassLayer.bounds.width - 48)
|
let width = min(max(180, textWidth + 34), min(460, maxWidth))
|
let height: CGFloat = 34
|
let x = max(24, (glassLayer.bounds.width - width) / 2)
|
let preferredY = waterfallLayer.frame.maxY - height - 14
|
let y = min(
|
max(16, preferredY),
|
max(16, glassLayer.bounds.height - height - 16)
|
)
|
|
closeFeedbackLayer.frame = CGRect(x: x, y: y, width: width, height: height)
|
closeFeedbackLayer.cornerRadius = height / 2
|
closeFeedbackLayer.shadowPath = CGPath(
|
roundedRect: closeFeedbackLayer.bounds,
|
cornerWidth: closeFeedbackLayer.cornerRadius,
|
cornerHeight: closeFeedbackLayer.cornerRadius,
|
transform: nil
|
)
|
closeFeedbackTextLayer.frame = closeFeedbackLayer.bounds.insetBy(dx: 17, dy: 8)
|
}
|
|
private func closeConfirmationBubblePath(
|
bounds: CGRect,
|
contentFrame: CGRect,
|
arrowEdge: CloseConfirmationArrowEdge,
|
arrowY: CGFloat
|
) -> CGPath {
|
let cornerRadius: CGFloat = 18
|
let arrowHalfHeight: CGFloat = 10
|
let path = CGMutablePath()
|
let minX = contentFrame.minX
|
let maxX = contentFrame.maxX
|
let minY = contentFrame.minY
|
let maxY = contentFrame.maxY
|
let arrowY = min(max(arrowY, minY + cornerRadius + arrowHalfHeight + 4), maxY - cornerRadius - arrowHalfHeight - 4)
|
|
path.move(to: CGPoint(x: minX + cornerRadius, y: minY))
|
path.addLine(to: CGPoint(x: maxX - cornerRadius, y: minY))
|
path.addQuadCurve(to: CGPoint(x: maxX, y: minY + cornerRadius), control: CGPoint(x: maxX, y: minY))
|
|
if arrowEdge == .right {
|
path.addLine(to: CGPoint(x: maxX, y: arrowY - arrowHalfHeight))
|
path.addLine(to: CGPoint(x: bounds.maxX, y: arrowY))
|
path.addLine(to: CGPoint(x: maxX, y: arrowY + arrowHalfHeight))
|
}
|
|
path.addLine(to: CGPoint(x: maxX, y: maxY - cornerRadius))
|
path.addQuadCurve(to: CGPoint(x: maxX - cornerRadius, y: maxY), control: CGPoint(x: maxX, y: maxY))
|
path.addLine(to: CGPoint(x: minX + cornerRadius, y: maxY))
|
path.addQuadCurve(to: CGPoint(x: minX, y: maxY - cornerRadius), control: CGPoint(x: minX, y: maxY))
|
|
if arrowEdge == .left {
|
path.addLine(to: CGPoint(x: minX, y: arrowY + arrowHalfHeight))
|
path.addLine(to: CGPoint(x: bounds.minX, y: arrowY))
|
path.addLine(to: CGPoint(x: minX, y: arrowY - arrowHalfHeight))
|
}
|
|
path.addLine(to: CGPoint(x: minX, y: minY + cornerRadius))
|
path.addQuadCurve(to: CGPoint(x: minX + cornerRadius, y: minY), control: CGPoint(x: minX, y: minY))
|
path.closeSubpath()
|
return path
|
}
|
|
private func confirmationTitle(for request: QuickSwitchCloseRequest) -> String {
|
switch request {
|
case .app:
|
return "关闭 \(request.appName)?"
|
case .window:
|
return "关闭这个窗口?"
|
}
|
}
|
|
private func confirmationMessage(for request: QuickSwitchCloseRequest) -> String {
|
switch request {
|
case .app:
|
return "将退出该应用,并一次性关闭它的所有窗口。"
|
case .window:
|
return "将关闭此窗口,未保存内容可能需要应用确认。"
|
}
|
}
|
|
private func closeRequest(for target: CloseTarget) -> QuickSwitchCloseRequest? {
|
switch target {
|
case .app(let appGroupIndex):
|
return appShelfItems
|
.first { $0.item.appGroupIndex == appGroupIndex }
|
.map { .app($0.item) }
|
case .window(let appGroupIndex, let windowIndex, let windowID):
|
guard let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex),
|
card.item.window.id == windowID
|
else {
|
return nil
|
}
|
return .window(card.item)
|
}
|
}
|
|
private func requestClose(for target: CloseTarget) {
|
guard let request = closeRequest(for: target) else {
|
DevelopmentDiagnostics.log("quickSwitch.view.closeRequest.missingTarget", [
|
"targetKind": target.kindDescription
|
])
|
return
|
}
|
|
pendingCloseTarget = nil
|
pendingCloseRequest = nil
|
pendingCloseOptOut = false
|
closeConfirmationLayers.containerLayer.isHidden = true
|
closeConfirmationArrowEdge = nil
|
onRequestClose?(request)
|
}
|
|
private func beginCloseConfirmation(for target: CloseTarget) {
|
if closeConfirmationRequired {
|
guard let request = closeRequest(for: target) else {
|
DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.missingRequest", [
|
"targetKind": target.kindDescription
|
])
|
return
|
}
|
pendingCloseTarget = target
|
pendingCloseRequest = request
|
pendingCloseOptOut = false
|
DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.show", [
|
"targetKind": target.kindDescription
|
])
|
needsLayout = true
|
return
|
}
|
|
requestClose(for: target)
|
}
|
|
private func confirmPendingClose() {
|
guard let target = pendingCloseTarget,
|
let request = pendingCloseRequest
|
else { return }
|
let shouldDisableConfirmation = pendingCloseOptOut
|
pendingCloseTarget = nil
|
pendingCloseRequest = nil
|
pendingCloseOptOut = false
|
closeConfirmationLayers.containerLayer.isHidden = true
|
closeConfirmationArrowEdge = nil
|
if shouldDisableConfirmation && closeConfirmationRequired {
|
closeConfirmationRequired = false
|
onCloseConfirmationDisabled?()
|
}
|
DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.confirm", [
|
"targetKind": target.kindDescription,
|
"disableFutureConfirmation": shouldDisableConfirmation
|
])
|
onRequestClose?(request)
|
}
|
|
private func cancelPendingClose() {
|
DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.cancel")
|
pendingCloseTarget = nil
|
pendingCloseRequest = nil
|
pendingCloseOptOut = false
|
closeConfirmationLayers.containerLayer.isHidden = true
|
closeConfirmationArrowEdge = nil
|
needsLayout = true
|
}
|
|
private func toggleCloseConfirmationOptOut() {
|
if pendingCloseOptOut && !closeConfirmationRequired {
|
return
|
}
|
pendingCloseOptOut.toggle()
|
if pendingCloseOptOut {
|
closeConfirmationRequired = false
|
onCloseConfirmationDisabled?()
|
DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.optOutPersisted")
|
}
|
needsLayout = true
|
}
|
|
private enum CloseConfirmationHit {
|
case confirm
|
case cancel
|
case checkbox
|
case background
|
case outside
|
}
|
|
private func closeConfirmationHit(at windowLocation: NSPoint) -> CloseConfirmationHit {
|
let location = convert(windowLocation, from: nil)
|
let locationInGlass = CGPoint(
|
x: location.x - glassLayer.frame.minX,
|
y: location.y - glassLayer.frame.minY
|
)
|
let layers = closeConfirmationLayers
|
guard !layers.containerLayer.isHidden else { return .outside }
|
guard layers.containerLayer.frame.contains(locationInGlass) else { return .outside }
|
|
let local = CGPoint(
|
x: locationInGlass.x - layers.containerLayer.frame.minX,
|
y: locationInGlass.y - layers.containerLayer.frame.minY
|
)
|
if layers.confirmButtonLayer.frame.contains(local) {
|
return .confirm
|
}
|
if layers.cancelButtonLayer.frame.contains(local) {
|
return .cancel
|
}
|
let checkboxHitFrame = layers.checkboxLayer.frame.union(layers.checkboxLabelLayer.frame).insetBy(dx: -6, dy: -4)
|
if checkboxHitFrame.contains(local) {
|
return .checkbox
|
}
|
return .background
|
}
|
|
private func handleCloseConfirmationMouseDown(at windowLocation: NSPoint) -> Bool {
|
switch closeConfirmationHit(at: windowLocation) {
|
case .confirm:
|
confirmPendingClose()
|
return true
|
case .cancel:
|
cancelPendingClose()
|
return true
|
case .checkbox:
|
toggleCloseConfirmationOptOut()
|
return true
|
case .background:
|
return true
|
case .outside:
|
if pendingCloseTarget != nil {
|
cancelPendingClose()
|
return true
|
}
|
return false
|
}
|
}
|
|
private func closeButtonTarget(at windowLocation: NSPoint) -> CloseTarget? {
|
if let target = appShelfCloseButtonTarget(at: windowLocation) {
|
return target
|
}
|
return waterfallCloseButtonTarget(at: windowLocation)
|
}
|
|
private func appShelfCloseButtonTarget(at windowLocation: NSPoint) -> CloseTarget? {
|
let locationInView = convert(windowLocation, from: nil)
|
let locationInAppShelfContent = CGPoint(
|
x: locationInView.x - glassLayer.frame.minX - shelfLayer.frame.minX - appShelfContentLayer.frame.minX,
|
y: locationInView.y - glassLayer.frame.minY - shelfLayer.frame.minY - appShelfContentLayer.frame.minY
|
)
|
|
for item in appShelfItems {
|
let target = CloseTarget.app(appGroupIndex: item.item.appGroupIndex)
|
guard item.closeButton.containerLayer.opacity > 0,
|
canClose(app: item.item.app)
|
else { continue }
|
|
let locationInItem = CGPoint(
|
x: locationInAppShelfContent.x - item.containerLayer.frame.minX,
|
y: locationInAppShelfContent.y - item.containerLayer.frame.minY
|
)
|
if item.closeButton.containerLayer.frame.insetBy(dx: -8, dy: -8).contains(locationInItem) {
|
return target
|
}
|
}
|
return nil
|
}
|
|
private func waterfallCloseButtonTarget(at windowLocation: NSPoint) -> CloseTarget? {
|
let locationInView = convert(windowLocation, from: nil)
|
let locationInWaterfallContent = CGPoint(
|
x: locationInView.x - glassLayer.frame.minX - waterfallLayer.frame.minX - waterfallContentLayer.frame.minX,
|
y: locationInView.y - glassLayer.frame.minY - waterfallLayer.frame.minY - waterfallContentLayer.frame.minY
|
)
|
|
for column in waterfallColumns where column.containerLayer.frame.contains(locationInWaterfallContent) {
|
let locationInColumn = CGPoint(
|
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 card.closeButton.containerLayer.opacity > 0 else { continue }
|
let locationInCard = CGPoint(
|
x: locationInColumn.x - card.containerLayer.frame.minX,
|
y: locationInColumn.y - card.containerLayer.frame.minY
|
)
|
if card.closeButton.containerLayer.frame.insetBy(dx: -8, dy: -8).contains(locationInCard) {
|
return .window(
|
appGroupIndex: card.item.appGroupIndex,
|
windowIndex: card.item.windowIndex,
|
windowID: card.item.window.id
|
)
|
}
|
}
|
}
|
return nil
|
}
|
|
private func closeConfirmationAnchor(for target: CloseTarget) -> CGPoint? {
|
switch target {
|
case .app(let appGroupIndex):
|
guard let item = appShelfItems.first(where: { $0.item.appGroupIndex == appGroupIndex }) else {
|
return nil
|
}
|
return CGPoint(
|
x: shelfLayer.frame.minX
|
+ appShelfContentLayer.frame.minX
|
+ item.containerLayer.frame.minX
|
+ item.closeButton.containerLayer.frame.midX,
|
y: shelfLayer.frame.minY
|
+ appShelfContentLayer.frame.minY
|
+ item.containerLayer.frame.minY
|
+ item.closeButton.containerLayer.frame.midY
|
)
|
case .window(let appGroupIndex, let windowIndex, let windowID):
|
guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }),
|
let card = column.cards.first(where: {
|
$0.item.windowIndex == windowIndex && $0.item.window.id == windowID
|
})
|
else {
|
return nil
|
}
|
return CGPoint(
|
x: waterfallLayer.frame.minX
|
+ waterfallContentLayer.frame.minX
|
+ column.containerLayer.frame.minX
|
+ card.containerLayer.frame.minX
|
+ card.closeButton.containerLayer.frame.midX,
|
y: waterfallLayer.frame.minY
|
+ waterfallContentLayer.frame.minY
|
+ column.containerLayer.frame.minY
|
+ card.containerLayer.frame.minY
|
+ card.closeButton.containerLayer.frame.midY
|
)
|
}
|
}
|
|
private func accentTintedWindowBackground(fraction: CGFloat, alpha: CGFloat) -> NSColor {
|
let color = NSColor.windowBackgroundColor.blended(withFraction: fraction, of: NSColor.controlAccentColor)
|
?? NSColor.windowBackgroundColor
|
return color.withAlphaComponent(alpha)
|
}
|
|
private func isWaterfallCardSpaceFocused(_ card: WaterfallCardLayers) -> Bool {
|
guard let activeSpaceFocusID else { return false }
|
return card.item.primarySpaceID == activeSpaceFocusID
|
}
|
|
private func waterfallColumnWidth() -> CGFloat {
|
guard waterfallColumns.count > 0 else { return WaterfallMetrics.columnWidth }
|
|
let visibleColumns = min(4, max(1, waterfallColumns.count))
|
let fittingWidth = (
|
waterfallLayer.bounds.width
|
- WaterfallMetrics.contentHorizontalPadding * 2
|
- CGFloat(max(0, visibleColumns - 1)) * WaterfallMetrics.gap
|
) / CGFloat(visibleColumns)
|
return max(
|
WaterfallMetrics.columnMinWidth,
|
min(WaterfallMetrics.columnMaxWidth, min(WaterfallMetrics.columnWidth, fittingWidth))
|
)
|
}
|
|
private var waterfallMaxScrollOffset: CGFloat {
|
max(0, waterfallContentWidth - waterfallLayer.bounds.width)
|
}
|
|
private func clampedWaterfallOffset(_ offset: CGFloat) -> CGFloat {
|
min(max(0, offset), waterfallMaxScrollOffset)
|
}
|
|
private func waterfallMaxVerticalScrollOffset(for appGroupIndex: Int) -> CGFloat {
|
guard
|
let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex })
|
else {
|
return 0
|
}
|
|
let visibleHeight = max(
|
1,
|
waterfallLayer.bounds.height
|
- WaterfallMetrics.headerHeight
|
- WaterfallMetrics.columnVerticalPadding * 2
|
)
|
let contentHeight = CGFloat(column.cards.count) * WaterfallMetrics.cardHeight
|
+ CGFloat(max(0, column.cards.count - 1)) * WaterfallMetrics.cardGap
|
return max(0, contentHeight - visibleHeight)
|
}
|
|
private func clampedWaterfallColumnOffset(_ offset: CGFloat, appGroupIndex: Int) -> CGFloat {
|
min(max(0, offset), waterfallMaxVerticalScrollOffset(for: appGroupIndex))
|
}
|
|
private func waterfallMetaText(for card: QuickSwitchWindowCardViewModel) -> String {
|
card.primarySpaceLabel ?? "No Space"
|
}
|
|
private func waterfallStateText(for card: QuickSwitchWindowCardViewModel) -> String {
|
var states: [String] = []
|
if card.window.isMinimized {
|
states.append("Minimized")
|
}
|
if card.window.isFullscreen {
|
states.append("Fullscreen")
|
}
|
if card.window.identifierSource == .syntheticAX {
|
states.append("AX")
|
}
|
|
return states.joined(separator: " / ")
|
}
|
|
private func spaceColor(for space: QuickSwitchSpaceViewModel) -> NSColor {
|
if space.windowCount > 0 {
|
return NSColor.controlBackgroundColor.withAlphaComponent(0.36)
|
}
|
|
return NSColor.clear
|
}
|
|
private var spaceLaneOccupiedColor: NSColor {
|
if effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
|
return NSColor(calibratedRed: 10 / 255, green: 132 / 255, blue: 1, alpha: 1)
|
}
|
|
return NSColor(calibratedRed: 0, green: 122 / 255, blue: 1, alpha: 1)
|
}
|
|
private func makeFullscreenMarkerLayer(for space: QuickSwitchSpaceViewModel) -> CALayer? {
|
guard space.type == .fullscreen else { return nil }
|
|
let shapeLayer = CAShapeLayer()
|
shapeLayer.fillColor = NSColor.clear.cgColor
|
shapeLayer.strokeColor = NSColor.secondaryLabelColor.withAlphaComponent(space.isCurrent ? 0.42 : 0.28).cgColor
|
shapeLayer.lineWidth = 1.2
|
shapeLayer.lineCap = .round
|
shapeLayer.lineJoin = .round
|
return shapeLayer
|
}
|
|
private func makeTextLayer(
|
string: String,
|
fontSize: CGFloat,
|
weight: NSFont.Weight,
|
color: NSColor,
|
alignment: CATextLayerAlignmentMode = .left
|
) -> CATextLayer {
|
let textLayer = CATextLayer()
|
textLayer.contentsScale = backingScaleFactor
|
textLayer.string = string
|
textLayer.font = NSFont.systemFont(ofSize: fontSize, weight: weight)
|
textLayer.fontSize = fontSize
|
textLayer.foregroundColor = color.cgColor
|
textLayer.alignmentMode = alignment
|
textLayer.truncationMode = .middle
|
return textLayer
|
}
|
|
private func makeCloseButtonLayers() -> CloseButtonLayers {
|
let container = CALayer()
|
container.cornerRadius = 8
|
container.masksToBounds = false
|
container.backgroundColor = NSColor.systemRed.withAlphaComponent(0.92).cgColor
|
container.borderColor = NSColor.white.withAlphaComponent(0.72).cgColor
|
container.borderWidth = 0.8
|
container.shadowColor = NSColor.black.cgColor
|
container.shadowOpacity = 0.14
|
container.shadowRadius = 5
|
container.shadowOffset = CGSize(width: 0, height: -1)
|
container.opacity = 0
|
|
let glyph = CAShapeLayer()
|
glyph.contentsScale = backingScaleFactor
|
glyph.strokeColor = NSColor.white.cgColor
|
glyph.fillColor = NSColor.clear.cgColor
|
glyph.lineCap = .round
|
glyph.lineJoin = .round
|
container.addSublayer(glyph)
|
|
return CloseButtonLayers(containerLayer: container, glyphLayer: glyph)
|
}
|
|
private func makeCloseConfirmationLayers() -> CloseConfirmationLayers {
|
let container = CALayer()
|
container.masksToBounds = false
|
container.backgroundColor = NSColor.clear.cgColor
|
container.shadowColor = NSColor.black.cgColor
|
container.shadowOpacity = 0.20
|
container.shadowRadius = 20
|
container.shadowOffset = CGSize(width: 0, height: -8)
|
container.zPosition = 200
|
|
let background = CAShapeLayer()
|
background.contentsScale = backingScaleFactor
|
background.fillColor = NSColor.windowBackgroundColor.withAlphaComponent(0.985).cgColor
|
background.strokeColor = NSColor.separatorColor.withAlphaComponent(0.36).cgColor
|
background.lineWidth = 1
|
|
let title = makeTextLayer(
|
string: "",
|
fontSize: 15,
|
weight: .semibold,
|
color: .labelColor
|
)
|
let message = makeTextLayer(
|
string: "",
|
fontSize: 13,
|
weight: .regular,
|
color: .secondaryLabelColor
|
)
|
message.truncationMode = .end
|
|
let checkbox = CALayer()
|
checkbox.cornerRadius = 4
|
checkbox.borderWidth = 1
|
checkbox.borderColor = NSColor.separatorColor.withAlphaComponent(0.48).cgColor
|
checkbox.backgroundColor = NSColor.clear.cgColor
|
|
let checkmark = makeTextLayer(
|
string: "",
|
fontSize: 11,
|
weight: .bold,
|
color: .white,
|
alignment: .center
|
)
|
|
let checkboxLabel = makeTextLayer(
|
string: "以后不再提醒",
|
fontSize: 13,
|
weight: .regular,
|
color: .secondaryLabelColor
|
)
|
|
let confirmButton = CALayer()
|
confirmButton.cornerRadius = 7
|
confirmButton.backgroundColor = NSColor.systemRed.withAlphaComponent(0.94).cgColor
|
|
let confirmText = makeTextLayer(
|
string: "关闭",
|
fontSize: 13,
|
weight: .semibold,
|
color: .white,
|
alignment: .center
|
)
|
|
let cancelButton = CALayer()
|
cancelButton.cornerRadius = 7
|
cancelButton.backgroundColor = NSColor.systemFill.withAlphaComponent(0.16).cgColor
|
cancelButton.borderColor = NSColor.separatorColor.withAlphaComponent(0.22).cgColor
|
cancelButton.borderWidth = 1
|
|
let cancelText = makeTextLayer(
|
string: "取消",
|
fontSize: 13,
|
weight: .medium,
|
color: .labelColor,
|
alignment: .center
|
)
|
|
container.addSublayer(background)
|
container.addSublayer(title)
|
container.addSublayer(message)
|
container.addSublayer(checkbox)
|
checkbox.addSublayer(checkmark)
|
container.addSublayer(checkboxLabel)
|
container.addSublayer(confirmButton)
|
confirmButton.addSublayer(confirmText)
|
container.addSublayer(cancelButton)
|
cancelButton.addSublayer(cancelText)
|
|
return CloseConfirmationLayers(
|
containerLayer: container,
|
backgroundLayer: background,
|
titleLayer: title,
|
messageLayer: message,
|
checkboxLayer: checkbox,
|
checkboxMarkLayer: checkmark,
|
checkboxLabelLayer: checkboxLabel,
|
confirmButtonLayer: confirmButton,
|
confirmButtonTextLayer: confirmText,
|
cancelButtonLayer: cancelButton,
|
cancelButtonTextLayer: cancelText
|
)
|
}
|
|
private func makeWindowBlocks(count: Int, space: QuickSwitchSpaceViewModel) -> [CALayer] {
|
guard count > 0 else { return [] }
|
|
return (0..<count).map { _ in
|
let layer = CALayer()
|
layer.cornerRadius = 3
|
layer.backgroundColor = spaceLaneOccupiedColor
|
.withAlphaComponent(space.isCurrent ? 0.72 : 0.58)
|
.cgColor
|
return layer
|
}
|
}
|
|
private func appIdentifierText(for space: QuickSwitchSpaceViewModel) -> String {
|
if space.type == .fullscreen,
|
let appName = space.appNames.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
!appName.isEmpty {
|
return appName
|
}
|
|
var identifiers = space.appNames.prefix(3).compactMap { name -> String? in
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
return trimmed.first.map { String($0) }
|
}
|
|
if space.appNames.count > 3 {
|
identifiers.append("+\(space.appNames.count - 3)")
|
}
|
|
return identifiers.joined(separator: " ")
|
}
|
|
private func layoutFullscreenMarkerLayer(_ layer: CALayer, in segmentBounds: CGRect) {
|
let markerFrame = fullscreenMarkerFrame(in: segmentBounds)
|
layer.frame = markerFrame
|
|
guard let shapeLayer = layer as? CAShapeLayer else { return }
|
|
let bounds = shapeLayer.bounds
|
let cornerLength = min(12, max(7, min(bounds.width, bounds.height) * 0.28))
|
let path = CGMutablePath()
|
|
path.move(to: CGPoint(x: 0, y: cornerLength))
|
path.addLine(to: CGPoint(x: 0, y: 0))
|
path.addLine(to: CGPoint(x: cornerLength, y: 0))
|
|
path.move(to: CGPoint(x: bounds.maxX - cornerLength, y: 0))
|
path.addLine(to: CGPoint(x: bounds.maxX, y: 0))
|
path.addLine(to: CGPoint(x: bounds.maxX, y: cornerLength))
|
|
path.move(to: CGPoint(x: bounds.maxX, y: bounds.maxY - cornerLength))
|
path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.maxY))
|
path.addLine(to: CGPoint(x: bounds.maxX - cornerLength, y: bounds.maxY))
|
|
path.move(to: CGPoint(x: cornerLength, y: bounds.maxY))
|
path.addLine(to: CGPoint(x: 0, y: bounds.maxY))
|
path.addLine(to: CGPoint(x: 0, y: bounds.maxY - cornerLength))
|
|
shapeLayer.path = path
|
}
|
|
private func fullscreenMarkerFrame(in segmentBounds: CGRect) -> CGRect {
|
CGRect(
|
x: 10,
|
y: 30,
|
width: max(1, segmentBounds.width - 20),
|
height: max(1, segmentBounds.height - 58)
|
)
|
}
|
|
private func columnSelectionHistoryReports() -> [[String: Int]] {
|
columnSelectionHistory
|
.sorted { $0.key < $1.key }
|
.map { appGroupIndex, windowIndex in
|
[
|
"appGroupIndex": appGroupIndex,
|
"windowIndex": windowIndex
|
]
|
}
|
}
|
|
private func appShelfItemReports() -> [[String: Any]] {
|
appShelfItems.enumerated().map { index, item in
|
let visibleFrame = item.containerLayer.frame.offsetBy(dx: appShelfContentLayer.frame.minX, dy: 0)
|
return [
|
"index": index,
|
"name": item.item.app.name,
|
"bundleIdentifier": item.item.app.bundleIdentifier,
|
"windowCount": item.item.windowCount,
|
"spaceFocusWindowCount": spaceFocusWindowCount(for: item.item.appGroupIndex),
|
"spaceFocusDirection": spaceFocusDirection(for: item.item.appGroupIndex),
|
"isSelected": item.item.appGroupIndex == effectiveSelection?.appGroupIndex,
|
"isHovered": item.item.appGroupIndex == effectiveHoveredAppGroupIndex,
|
"isSpaceFocused": spaceFocusWindowCount(for: item.item.appGroupIndex) > 0,
|
"visualStates": appShelfVisualStates(for: item),
|
"zPosition": Double(item.containerLayer.zPosition),
|
"hasMinimizedWindows": item.item.hasMinimizedWindows,
|
"hasFullscreenWindows": item.item.hasFullscreenWindows,
|
"frame": dictionary(from: item.containerLayer.frame),
|
"visibleFrame": dictionary(from: visibleFrame),
|
"iconFrame": dictionary(from: item.iconLayer.frame),
|
"labelFrame": dictionary(from: item.labelLayer.frame),
|
"labelTruncationMode": "middle",
|
"badgeVisible": !item.badgeLayer.isHidden,
|
"badgeText": item.badgeLayer.string ?? "",
|
"closeButtonVisible": item.closeButton.containerLayer.opacity > 0,
|
"closeButtonFrame": dictionary(from: item.closeButton.containerLayer.frame),
|
"closeButtonSize": Double(item.closeButton.containerLayer.bounds.width),
|
"closeButtonFillColor": colorDictionary(from: item.closeButton.containerLayer.backgroundColor),
|
"closeButtonGlyphColor": colorDictionary(from: item.closeButton.glyphLayer.strokeColor),
|
"closeButtonGlyphLineWidth": Double(item.closeButton.glyphLayer.lineWidth)
|
] as [String: Any]
|
}
|
}
|
|
private func appShelfVisualStates(for item: AppShelfItemLayers) -> [String] {
|
var states: [String] = []
|
let isSelected = item.item.appGroupIndex == effectiveSelection?.appGroupIndex
|
let isHovered = item.item.appGroupIndex == effectiveHoveredAppGroupIndex
|
if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) {
|
states.append("selected")
|
} else if isSelected {
|
states.append("selectedVisualSuppressed")
|
}
|
if isHovered {
|
states.append("hover")
|
}
|
if spaceFocusWindowCount(for: item.item.appGroupIndex) > 0 {
|
states.append("spaceFocused")
|
let direction = spaceFocusDirection(for: item.item.appGroupIndex)
|
if direction != "none" {
|
states.append("spaceFocusDirection:\(direction)")
|
}
|
}
|
return states
|
}
|
|
private func waterfallColumnVisualStates(for column: WaterfallColumnLayers) -> [String] {
|
var states: [String] = []
|
if column.column.appGroupIndex == effectiveHoveredAppGroupIndex {
|
states.append("hover")
|
}
|
if spaceFocusWindowCount(for: column.column.appGroupIndex) > 0 {
|
states.append("spaceFocused")
|
}
|
return states
|
}
|
|
private func waterfallColumnReports() -> [[String: Any]] {
|
waterfallColumns.map { column in
|
let visibleFrame = column.containerLayer.frame.offsetBy(dx: waterfallContentLayer.frame.minX, dy: 0)
|
let appGroupIndex = column.column.appGroupIndex
|
let isHovered = appGroupIndex == effectiveHoveredAppGroupIndex
|
let firstCardFrame = column.cards.first?.containerLayer.frame
|
let headerToFirstCardGap = firstCardFrame.map {
|
column.headerLayer.frame.minY - $0.maxY
|
}
|
let topToFirstCardGap = firstCardFrame.map {
|
column.containerLayer.bounds.height - $0.maxY
|
}
|
|
return [
|
"appGroupIndex": appGroupIndex,
|
"appName": column.column.app.name,
|
"bundleIdentifier": column.column.app.bundleIdentifier,
|
"windowCount": column.column.windows.count,
|
"isHovered": isHovered,
|
"visualStates": waterfallColumnVisualStates(for: column),
|
"spaceFocusWindowCount": spaceFocusWindowCount(for: appGroupIndex),
|
"isSpaceFocused": spaceFocusWindowCount(for: appGroupIndex) > 0,
|
"spaceFocusDirection": spaceFocusDirection(for: appGroupIndex),
|
"frame": dictionary(from: column.containerLayer.frame),
|
"visibleFrame": dictionary(from: visibleFrame),
|
"headerFrame": dictionary(from: column.headerLayer.frame),
|
"headerToFirstCardGap": headerToFirstCardGap.map(Double.init) ?? NSNull(),
|
"topToFirstCardGap": topToFirstCardGap.map(Double.init) ?? NSNull(),
|
"appNameFrame": dictionary(from: column.appNameLayer.frame),
|
"appNameAlignment": column.appNameLayer.alignmentMode.rawValue,
|
"appNameColor": colorDictionary(from: column.appNameLayer.foregroundColor),
|
"countFrame": dictionary(from: column.countLayer.frame),
|
"verticalScrollOffset": Double(waterfallColumnScrollOffsets[appGroupIndex] ?? 0),
|
"maxVerticalScrollOffset": Double(waterfallMaxVerticalScrollOffset(for: appGroupIndex)),
|
"cards": waterfallCardReports(for: column)
|
] as [String: Any]
|
}
|
}
|
|
private func appColumnAlignmentReports() -> [[String: Any]] {
|
appShelfItems.compactMap { item in
|
guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == item.item.appGroupIndex }) else {
|
return nil
|
}
|
|
let appIconCenterX = item.containerLayer.frame.minX
|
+ item.iconLayer.frame.midX
|
+ appShelfContentLayer.frame.minX
|
let visibleColumnFrame = column.containerLayer.frame.offsetBy(dx: waterfallContentLayer.frame.minX, dy: 0)
|
let columnCenterX = visibleColumnFrame.midX
|
let deltaX = columnCenterX - appIconCenterX
|
let rightBlankWidth = max(0, waterfallLayer.bounds.width - visibleColumnFrame.maxX)
|
let leftBlankWidth = max(0, visibleColumnFrame.minX)
|
|
return [
|
"appGroupIndex": item.item.appGroupIndex,
|
"appName": item.item.app.name,
|
"appIconCenterX": Double(appIconCenterX),
|
"columnCenterX": Double(columnCenterX),
|
"deltaX": Double(deltaX),
|
"absDeltaX": Double(abs(deltaX)),
|
"tolerance": Double(WaterfallAlignmentMetrics.tolerance),
|
"passed": abs(deltaX) <= WaterfallAlignmentMetrics.tolerance,
|
"waterfallScrollOffset": Double(waterfallScrollOffset),
|
"waterfallMaxScrollOffset": Double(waterfallMaxScrollOffset),
|
"waterfallAlignmentMinScrollOffset": Double(waterfallAlignmentScrollRange.lowerBound),
|
"waterfallAlignmentMaxScrollOffset": Double(waterfallAlignmentScrollRange.upperBound),
|
"rightBlankWidth": Double(rightBlankWidth),
|
"leftBlankWidth": Double(leftBlankWidth),
|
"alignmentClampedReason": alignmentClampedReason(for: item, column: column)
|
] as [String: Any]
|
}
|
}
|
|
private func alignmentClampedReason(for item: AppShelfItemLayers, column: WaterfallColumnLayers) -> String {
|
let targetOffset = targetWaterfallOffsetAligning(column: column, to: item)
|
let range = waterfallAlignmentScrollRange
|
if targetOffset < range.lowerBound - WaterfallAlignmentMetrics.tolerance {
|
return "leading"
|
}
|
if targetOffset > range.upperBound + WaterfallAlignmentMetrics.tolerance {
|
return "trailing"
|
}
|
return "none"
|
}
|
|
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
|
)
|
let screenshotSource = screenshotSourcesByWindowID[card.item.window.id]
|
|
var report = [
|
"appGroupIndex": card.item.appGroupIndex,
|
"windowIndex": card.item.windowIndex,
|
"globalIndex": card.item.globalIndex,
|
"windowID": Int(card.item.window.id),
|
"titleHash": DevelopmentDiagnostics.stableFingerprint(card.item.window.title),
|
"titleLength": card.item.window.title.count,
|
"titleIsEmpty": card.item.window.title.isEmpty,
|
"primarySpaceID": card.item.primarySpaceID ?? NSNull(),
|
"primarySpaceLabel": card.item.primarySpaceLabel ?? NSNull(),
|
"isMinimized": card.item.window.isMinimized,
|
"isFullscreen": card.item.window.isFullscreen,
|
"identifierSource": "\(card.item.window.identifierSource)",
|
"isSelected": card.item.window.id == effectiveSelection?.windowID,
|
"visualStates": waterfallCardVisualStates(for: card),
|
"thumbnailStrategy": thumbnailStrategyString(for: card.item.window),
|
"screenshotSource": screenshotSourceString(screenshotSource, for: card.item.window),
|
"screenshotFallbackReason": screenshotFallbackReasonString(screenshotSource),
|
"screenshotNotRequestedReason": screenshotSkippedReasonsByWindowID[card.item.window.id] ?? NSNull(),
|
"backgroundColor": colorDictionary(from: card.containerLayer.backgroundColor),
|
"shadowOpacity": Double(card.containerLayer.shadowOpacity),
|
"shadowRadius": Double(card.containerLayer.shadowRadius),
|
"thumbnailFrame": dictionary(from: card.thumbnailLayer.frame),
|
"titleBarFrame": dictionary(from: card.titleBarLayer.frame),
|
"titleBarBorderWidth": Double(card.titleBarLayer.borderWidth),
|
"titleFrame": dictionary(from: card.titleLayer.frame),
|
"metaFrame": dictionary(from: card.metaLayer.frame),
|
"appIconFrame": dictionary(from: card.appIconLayer.frame),
|
"stateFrame": dictionary(from: card.stateLayer.frame),
|
"closeButtonVisible": card.closeButton.containerLayer.opacity > 0,
|
"closeButtonFrame": dictionary(from: card.closeButton.containerLayer.frame),
|
"closeButtonSize": Double(card.closeButton.containerLayer.bounds.width),
|
"closeButtonFillColor": colorDictionary(from: card.closeButton.containerLayer.backgroundColor),
|
"closeButtonGlyphColor": colorDictionary(from: card.closeButton.glyphLayer.strokeColor),
|
"closeButtonGlyphLineWidth": Double(card.closeButton.glyphLayer.lineWidth),
|
"shineVisible": card.shineLayer.opacity > 0,
|
"zPosition": Double(card.containerLayer.zPosition),
|
"frame": dictionary(from: card.containerLayer.frame),
|
"visibleFrame": dictionary(from: visibleFrame),
|
"titleTruncationMode": "middle"
|
] as [String: Any]
|
if DevelopmentDiagnostics.includesSensitiveFields {
|
report["title"] = card.item.window.title
|
}
|
return report
|
}
|
}
|
|
private func waterfallCardsByWindowID() -> [UInt32: WaterfallCardLayers] {
|
var cards: [UInt32: WaterfallCardLayers] = [:]
|
for column in waterfallColumns {
|
for card in column.cards {
|
cards[card.item.window.id] = card
|
}
|
}
|
return cards
|
}
|
|
private var allWaterfallWindows: [AlignerWindow] {
|
currentViewModel?.waterfallColumns.flatMap { column in
|
column.windows.map(\.window)
|
} ?? []
|
}
|
|
private var screenshotEligibleWindows: [AlignerWindow] {
|
allWaterfallWindows.filter(Self.shouldRequestScreenshot)
|
}
|
|
private var screenshotPendingWindows: [AlignerWindow] {
|
screenshotEligibleWindows.filter { window in
|
screenshotSourcesByWindowID[window.id] == nil
|
&& screenshotSkippedReasonsByWindowID[window.id] == nil
|
}
|
}
|
|
private static func shouldRequestScreenshot(for window: AlignerWindow) -> Bool {
|
switch ThumbnailPolicy.preferredStrategy(for: window.app.category) {
|
case .screenshotPreferred:
|
return true
|
case .skeletonPreferred, .fallbackSkeleton:
|
return false
|
}
|
}
|
|
private func thumbnailStrategyString(for window: AlignerWindow) -> String {
|
if let source = screenshotSourcesByWindowID[window.id] {
|
return screenshotSourceString(source, for: window)
|
}
|
|
if let skippedReason = screenshotSkippedReasonsByWindowID[window.id] {
|
return skippedReason == "skeletonPreferred" ? "skeletonPreferred" : "skeleton"
|
}
|
|
switch ThumbnailPolicy.preferredStrategy(for: window.app.category) {
|
case .skeletonPreferred:
|
return "skeletonPreferred"
|
case .screenshotPreferred:
|
return "pendingScreenshot"
|
case .fallbackSkeleton:
|
return "skeleton"
|
}
|
}
|
|
private func screenshotSourceString(_ source: ScreenshotResolutionSource?, for window: AlignerWindow) -> String {
|
guard let source else {
|
return Self.shouldRequestScreenshot(for: window)
|
&& screenshotSkippedReasonsByWindowID[window.id] == nil
|
? "pending"
|
: "notRequested"
|
}
|
|
switch source {
|
case .realScreenshot:
|
return "realScreenshot"
|
case .skeletonFallback:
|
return "skeletonFallback"
|
}
|
}
|
|
private func screenshotFallbackReasonString(_ source: ScreenshotResolutionSource?) -> Any {
|
guard case .skeletonFallback(let reason) = source else {
|
return NSNull()
|
}
|
|
return reason.diagnosticDescription
|
}
|
|
private func waterfallCardVisualStates(for card: WaterfallCardLayers) -> [String] {
|
var states: [String] = []
|
let isSelected = card.item.window.id == effectiveSelection?.windowID
|
let isHovered = card.item.window.id == hoveredWindowID
|
if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) {
|
states.append("selected")
|
} else if isSelected {
|
states.append("selectedVisualSuppressed")
|
}
|
if isHovered {
|
states.append("hover")
|
}
|
if isWaterfallCardSpaceFocused(card) {
|
states.append("spaceFocused")
|
}
|
if card.item.window.isMinimized {
|
states.append("minimized")
|
}
|
if card.item.window.isFullscreen {
|
states.append("fullscreen")
|
}
|
return states
|
}
|
|
private var backingScaleFactor: CGFloat {
|
window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
|
}
|
|
private func spaceLaneDisplayGroupReports() -> [[String: Any]] {
|
spaceLaneDisplayGroups.map { group in
|
let visibleFrame = group.containerLayer.frame.offsetBy(dx: spaceLaneContentLayer.frame.minX, dy: 0)
|
return [
|
"displayUUID": group.display.displayUUID,
|
"displayIndex": group.display.displayIndex,
|
"displayLabel": group.display.displayLabel,
|
"physical": group.display.physical,
|
"spaceCount": group.segments.count,
|
"spaceIDs": group.segments.map(\.space.id),
|
"spaceLabels": group.segments.map(\.space.label),
|
"frame": dictionary(from: group.containerLayer.frame),
|
"visibleFrame": dictionary(from: visibleFrame),
|
"backgroundColor": colorDictionary(from: group.containerLayer.backgroundColor),
|
"borderColor": colorDictionary(from: group.containerLayer.borderColor),
|
"borderWidth": Double(group.containerLayer.borderWidth),
|
"shadowOpacity": Double(group.containerLayer.shadowOpacity),
|
"shadowRadius": Double(group.containerLayer.shadowRadius),
|
"groupGap": Double(SpaceLaneMetrics.groupGap),
|
"groupHorizontalPadding": Double(SpaceLaneMetrics.groupHorizontalPadding),
|
"groupVerticalPadding": Double(SpaceLaneMetrics.groupVerticalPadding),
|
"segmentGap": Double(SpaceLaneMetrics.segmentGap)
|
] as [String: Any]
|
}
|
}
|
|
private func spaceLaneSegmentReports() -> [[String: Any]] {
|
currentViewModel?.displays.flatMap { display in
|
display.spaces.compactMap { space in
|
guard let segment = spaceLaneSegments.first(where: { $0.space.id == space.id }) else {
|
return nil
|
}
|
let isAppAssociated = appHoverAssociatedSpaceIDs.contains(space.id)
|
let isWindowAssociated = space.id == windowHoverAssociatedSpaceID
|
let group = spaceLaneDisplayGroup(containingSpaceID: space.id)
|
let visibleFrame = segment.containerLayer.frame.offsetBy(
|
dx: (group?.containerLayer.frame.minX ?? 0) + spaceLaneContentLayer.frame.minX,
|
dy: group?.containerLayer.frame.minY ?? 0
|
)
|
return [
|
"displayUUID": display.displayUUID,
|
"displayLabel": display.displayLabel,
|
"spaceID": space.id,
|
"label": space.label,
|
"type": spaceTypeName(space.type),
|
"visualStates": visualStates(for: space),
|
"fullscreenMarkerVisible": segment.fullscreenMarkerLayer != nil,
|
"fullscreenMarkerKind": segment.fullscreenMarkerLayer == nil ? "none" : "cornerBracket",
|
"fullscreenMarkerFrame": segment.fullscreenMarkerLayer.map { dictionary(from: $0.frame) } ?? NSNull(),
|
"windowBlockCount": segment.windowBlocks.count,
|
"windowBlockColors": segment.windowBlocks.map { colorDictionary(from: $0.backgroundColor) },
|
"windowBlockFrames": segment.windowBlocks.map { dictionary(from: $0.frame) },
|
"backgroundColor": colorDictionary(from: segment.containerLayer.backgroundColor),
|
"borderColor": colorDictionary(from: segment.containerLayer.borderColor),
|
"borderWidth": Double(segment.containerLayer.borderWidth),
|
"shadowOpacity": Double(segment.containerLayer.shadowOpacity),
|
"shadowRadius": Double(segment.containerLayer.shadowRadius),
|
"appFrame": dictionary(from: segment.appLayer.frame),
|
"frame": dictionary(from: segment.containerLayer.frame),
|
"visibleFrame": dictionary(from: visibleFrame),
|
"isCurrent": space.isCurrent,
|
"isHovered": space.id == activeSpaceFocusID,
|
"isFocused": space.id == activeSpaceFocusID,
|
"isAppAssociated": isAppAssociated,
|
"isWindowAssociated": isWindowAssociated,
|
"windowCount": space.windowCount,
|
"appCount": space.appCount,
|
"appNames": space.appNames
|
] as [String: Any]
|
}
|
} ?? []
|
}
|
|
private func spaceLaneDisplayGroup(containingSpaceID spaceID: UInt64) -> SpaceLaneDisplayGroupLayers? {
|
spaceLaneDisplayGroups.first { group in
|
group.segments.contains { $0.space.id == spaceID }
|
}
|
}
|
|
private func visualStates(for space: QuickSwitchSpaceViewModel) -> [String] {
|
var states: [String] = []
|
if space.isCurrent {
|
states.append("current")
|
}
|
if appHoverAssociatedSpaceIDs.contains(space.id) {
|
states.append("appAssociated")
|
}
|
if space.id == windowHoverAssociatedSpaceID {
|
states.append("windowAssociated")
|
}
|
if space.id == activeSpaceFocusID {
|
states.append("hover")
|
}
|
if space.id == activeSpaceFocusID {
|
states.append("focused")
|
}
|
if space.type == .fullscreen {
|
states.append("fullscreen")
|
}
|
return states
|
}
|
|
private func spaceTypeName(_ type: AlignerSpaceType) -> String {
|
switch type {
|
case .user:
|
return "user"
|
case .fullscreen:
|
return "fullscreen"
|
case .system:
|
return "system"
|
case .unknown(let rawValue):
|
return "unknown(\(rawValue))"
|
}
|
}
|
|
private func dictionary(from rect: CGRect) -> [String: Double] {
|
[
|
"x": Double(rect.origin.x),
|
"y": Double(rect.origin.y),
|
"width": Double(rect.width),
|
"height": Double(rect.height)
|
]
|
}
|
|
private func colorDictionary(from color: CGColor?) -> [String: Double] {
|
guard let color,
|
let nsColor = NSColor(cgColor: color)?.usingColorSpace(.deviceRGB)
|
else {
|
return [
|
"red": 0,
|
"green": 0,
|
"blue": 0,
|
"alpha": 0
|
]
|
}
|
|
return [
|
"red": Double(nsColor.redComponent),
|
"green": Double(nsColor.greenComponent),
|
"blue": Double(nsColor.blueComponent),
|
"alpha": Double(nsColor.alphaComponent)
|
]
|
}
|
}
|