Files
ggbond268--mactools/Plugins/WindowSwitcher/Sources/WindowSwitcherOverlayController.swift
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

844 lines
28 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
private enum WindowSwitcherOverlayPresentation {
case direct
case keyWindow
}
enum WindowSwitcherOverlayMetrics {
static let cornerRadius: CGFloat = 22
static let iconSize: CGFloat = 60
static let iconLayoutSize: CGFloat = 80
static let iconHighlightPadding: CGFloat = 2
static let iconHighlightSize = iconSize + iconHighlightPadding * 2
static let iconHighlightCornerRadius: CGFloat = 14
static let quitButtonSize: CGFloat = 18
static let quitButtonGap: CGFloat = 1
static let horizontalSpacing: CGFloat = 14
static let verticalSpacing: CGFloat = 12
static let panelHorizontalPadding: CGFloat = 20
static let panelVerticalPadding: CGFloat = 18
static let directTopSpacing: CGFloat = 14
static let shortcutHeight: CGFloat = 22
static let shortcutContentSpacing: CGFloat = 7
static let iconLabelSpacing: CGFloat = 6
static let labelAreaHeight: CGFloat = 27
static let tileContentHeight = iconLayoutSize + iconLabelSpacing + labelAreaHeight
static let directTileWidth: CGFloat = 106
static let keyWindowTileWidth: CGFloat = 112
static let directTileHeight = directTopSpacing + tileContentHeight
static let keyWindowTileHeight = shortcutHeight + shortcutContentSpacing + tileContentHeight
static func quitButtonCenter(tileWidth: CGFloat, contentTopOffset: CGFloat) -> CGPoint {
let highlightTopInset = (iconLayoutSize - iconHighlightSize) / 2
let cornerCenter = CGPoint(
x: tileWidth / 2 + iconHighlightSize / 2 - iconHighlightCornerRadius,
y: contentTopOffset + highlightTopInset + iconHighlightCornerRadius
)
let diagonalOffset = (
iconHighlightCornerRadius + quitButtonSize / 2 + quitButtonGap
) / sqrt(2)
return CGPoint(
x: cornerCenter.x + diagonalOffset,
y: cornerCenter.y - diagonalOffset
)
}
static func roundedMaskImage(
cornerRadius: CGFloat = WindowSwitcherOverlayMetrics.cornerRadius
) -> NSImage {
let edge = cornerRadius * 2 + 1
let image = NSImage(size: NSSize(width: edge, height: edge), flipped: false) { rect in
NSColor.black.setFill()
NSBezierPath(
roundedRect: rect,
xRadius: cornerRadius,
yRadius: cornerRadius
).fill()
return true
}
image.capInsets = NSEdgeInsets(
top: cornerRadius,
left: cornerRadius,
bottom: cornerRadius,
right: cornerRadius
)
image.resizingMode = .stretch
return image
}
}
@MainActor
private final class WindowSwitcherOverlayModel: ObservableObject {
@Published var entries: [WindowSwitcherAppEntry] = []
@Published var selectedID: String?
@Published var recordingEntryID: String?
@Published var recordingCandidate: String?
@Published var recordingHasConflict: Bool = false
@Published var presentation: WindowSwitcherOverlayPresentation = .keyWindow
@Published var columnCount: Int = 1
@Published var shortcutText: String = ""
}
@MainActor
final class WindowSwitcherOverlayController: NSObject, NSWindowDelegate {
var onSelect: ((WindowSwitcherAppEntry) -> Void)?
var onQuit: ((WindowSwitcherAppEntry) -> Void)?
var onShortcutChange: ((WindowSwitcherAppEntry, String?) -> WindowSwitcherShortcutCustomizationResult)?
var onCancel: (() -> Void)?
private final class KeyablePanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
private let model = WindowSwitcherOverlayModel()
private var panel: KeyablePanel?
private var keyMonitor: Any?
private var clickMonitor: Any?
private var hostingView: NSView?
private var isClosing = false
var isVisible: Bool {
panel?.isVisible ?? false
}
func showDirect(
entries: [WindowSwitcherAppEntry],
selectedID: String?,
shortcutText: String
) {
show(
entries: entries,
selectedID: selectedID,
presentation: .direct,
shortcutText: shortcutText
)
}
func showKeyWindow(
entries: [WindowSwitcherAppEntry],
shortcutText: String
) {
show(
entries: entries,
selectedID: nil,
presentation: .keyWindow,
shortcutText: shortcutText
)
}
func updateDirectSelection(selectedID: String?) {
model.selectedID = selectedID
}
func updateEntries(entries: [WindowSwitcherAppEntry], selectedID: String?) {
model.entries = entries
model.selectedID = selectedID
if let recordingEntryID = model.recordingEntryID,
!entries.contains(where: { $0.id == recordingEntryID }) {
cancelShortcutRecording()
}
model.columnCount = columnCount(
for: entries.count,
presentation: model.presentation,
screen: targetScreen()
)
if entries.isEmpty {
hide()
} else if let panel {
resize(panel)
center(panel, on: targetScreen())
}
}
func hide() {
model.recordingEntryID = nil
model.recordingCandidate = nil
model.recordingHasConflict = false
removeKeyMonitor()
removeClickMonitor()
isClosing = true
panel?.orderOut(nil)
isClosing = false
}
private func show(
entries: [WindowSwitcherAppEntry],
selectedID: String?,
presentation: WindowSwitcherOverlayPresentation,
shortcutText: String
) {
let screen = targetScreen()
model.entries = entries
model.selectedID = selectedID
model.recordingEntryID = nil
model.recordingCandidate = nil
model.recordingHasConflict = false
model.presentation = presentation
model.shortcutText = shortcutText
model.columnCount = columnCount(for: entries.count, presentation: presentation, screen: screen)
let panel = panel ?? makePanel()
self.panel = panel
resize(panel)
center(panel, on: screen)
switch presentation {
case .direct:
removeKeyMonitor()
removeClickMonitor()
panel.orderFrontRegardless()
case .keyWindow:
installKeyMonitor()
installClickMonitor()
NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)
}
}
private func makePanel() -> KeyablePanel {
let panel = KeyablePanel(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 220),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.level = .popUpMenu
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = true
panel.isMovableByWindowBackground = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false
panel.animationBehavior = .none
panel.delegate = self
let hostingView = NSHostingView(
rootView: WindowSwitcherOverlayView(model: model) { [weak self] entry in
self?.select(entry)
} onQuit: { [weak self] entry in
self?.quit(entry)
} onRecordShortcut: { [weak self] entry in
self?.beginShortcutRecording(entry)
} onCancelShortcutRecording: { [weak self] in
self?.cancelShortcutRecording()
}
)
hostingView.wantsLayer = true
hostingView.layer?.cornerRadius = WindowSwitcherOverlayMetrics.cornerRadius
hostingView.layer?.cornerCurve = .continuous
hostingView.layer?.masksToBounds = true
self.hostingView = hostingView
panel.contentView = makeGlassContentView(hostingView: hostingView)
return panel
}
private func makeGlassContentView(hostingView: NSView) -> NSView {
if #available(macOS 26.0, *) {
let glassView = NSGlassEffectView()
glassView.style = .regular
glassView.cornerRadius = WindowSwitcherOverlayMetrics.cornerRadius
glassView.contentView = hostingView
glassView.wantsLayer = true
glassView.layer?.cornerRadius = WindowSwitcherOverlayMetrics.cornerRadius
glassView.layer?.cornerCurve = .continuous
glassView.layer?.masksToBounds = true
return glassView
}
let effectView = NSVisualEffectView()
effectView.material = .hudWindow
effectView.blendingMode = .behindWindow
effectView.state = .active
effectView.maskImage = WindowSwitcherOverlayMetrics.roundedMaskImage()
effectView.wantsLayer = true
effectView.layer?.cornerRadius = WindowSwitcherOverlayMetrics.cornerRadius
effectView.layer?.cornerCurve = .continuous
effectView.layer?.masksToBounds = true
hostingView.translatesAutoresizingMaskIntoConstraints = false
effectView.addSubview(hostingView)
NSLayoutConstraint.activate([
hostingView.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
hostingView.topAnchor.constraint(equalTo: effectView.topAnchor),
hostingView.bottomAnchor.constraint(equalTo: effectView.bottomAnchor),
])
return effectView
}
private func resize(_ panel: NSPanel) {
guard let hostingView else {
return
}
hostingView.layoutSubtreeIfNeeded()
panel.setContentSize(hostingView.fittingSize)
panel.invalidateShadow()
}
private func center(_ panel: NSPanel, on screen: NSScreen?) {
guard let frame = screen?.visibleFrame ?? NSScreen.main?.visibleFrame else {
return
}
let size = panel.frame.size
let origin = NSPoint(
x: frame.midX - size.width / 2,
y: frame.midY - size.height / 2
)
panel.setFrameOrigin(origin)
}
private func targetScreen() -> NSScreen? {
let mouseLocation = NSEvent.mouseLocation
return NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }
?? NSScreen.main
}
private func columnCount(
for entryCount: Int,
presentation: WindowSwitcherOverlayPresentation,
screen: NSScreen?
) -> Int {
guard entryCount > 0 else {
return 1
}
let frame = screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 900, height: 700)
let tileWidth = presentation == .direct
? WindowSwitcherOverlayMetrics.directTileWidth
: WindowSwitcherOverlayMetrics.keyWindowTileWidth
let horizontalPadding = WindowSwitcherOverlayMetrics.panelHorizontalPadding * 2
let spacing = WindowSwitcherOverlayMetrics.horizontalSpacing
let maxWidth = min(frame.width - 96, presentation == .direct ? 980 : 1040)
let capacity = Int((maxWidth - horizontalPadding + spacing) / (tileWidth + spacing))
return max(1, min(entryCount, capacity))
}
private func installKeyMonitor() {
removeKeyMonitor()
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else {
return event
}
return self.handleKeyDown(event) ? nil : event
}
}
private func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
}
keyMonitor = nil
}
private func installClickMonitor() {
removeClickMonitor()
clickMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.cancel()
}
}
}
private func removeClickMonitor() {
if let clickMonitor {
NSEvent.removeMonitor(clickMonitor)
}
clickMonitor = nil
}
private func handleKeyDown(_ event: NSEvent) -> Bool {
guard model.presentation == .keyWindow else {
return false
}
if model.recordingEntryID != nil {
return handleRecordingKeyDown(event)
}
if event.keyCode == 53 {
cancel()
return true
}
guard let shortcut = selectionShortcut(from: event) else {
return false
}
resolveShortcutToken(shortcut.storageValue)
return true
}
private func handleRecordingKeyDown(_ event: NSEvent) -> Bool {
if event.keyCode == 53 {
cancelShortcutRecording()
return true
}
if event.keyCode == 51 || event.keyCode == 117 {
commitRecordedShortcut(nil)
return true
}
guard let shortcut = selectionShortcut(from: event) else {
NSSound.beep()
return true
}
commitRecordedShortcut(shortcut.storageValue)
return true
}
private func selectionShortcut(from event: NSEvent) -> WindowSwitcherSelectionShortcut? {
guard event.modifierFlags.intersection([.control, .option]).isEmpty,
let chars = event.charactersIgnoringModifiers?.lowercased(),
let character = chars.first
else {
return nil
}
return WindowSwitcherSelectionShortcut(
key: String(character),
usesCommand: event.modifierFlags.contains(.command)
)
}
private func resolveShortcutToken(_ token: String) {
guard let entry = model.entries.first(where: { $0.shortcutToken == token }) else {
NSSound.beep()
return
}
select(entry)
}
private func select(_ entry: WindowSwitcherAppEntry) {
cancelShortcutRecording()
hide()
onSelect?(entry)
}
private func beginShortcutRecording(_ entry: WindowSwitcherAppEntry) {
guard model.presentation == .keyWindow else {
return
}
cancelShortcutRecording()
model.recordingEntryID = entry.id
model.recordingCandidate = nil
model.recordingHasConflict = false
}
private func commitRecordedShortcut(_ token: String?) {
guard let entryID = model.recordingEntryID,
let entry = model.entries.first(where: { $0.id == entryID }),
let onShortcutChange
else {
cancelShortcutRecording()
return
}
model.recordingCandidate = token
switch onShortcutChange(entry, token) {
case let .updated(entries):
model.entries = entries
cancelShortcutRecording()
case .conflict:
model.recordingHasConflict = true
NSSound.beep()
case .unavailable:
cancelShortcutRecording()
NSSound.beep()
}
}
private func cancelShortcutRecording() {
model.recordingEntryID = nil
model.recordingCandidate = nil
model.recordingHasConflict = false
}
private func quit(_ entry: WindowSwitcherAppEntry) {
cancelShortcutRecording()
onQuit?(entry)
}
private func cancel() {
cancelShortcutRecording()
hide()
onCancel?()
}
func windowDidResignKey(_ notification: Notification) {
guard !isClosing, model.presentation == .keyWindow else {
return
}
cancel()
}
}
@MainActor
private struct WindowSwitcherOverlayView: View {
@ObservedObject var model: WindowSwitcherOverlayModel
let onSelect: (WindowSwitcherAppEntry) -> Void
let onQuit: (WindowSwitcherAppEntry) -> Void
let onRecordShortcut: (WindowSwitcherAppEntry) -> Void
let onCancelShortcutRecording: () -> Void
private var tileWidth: CGFloat {
model.presentation == .direct
? WindowSwitcherOverlayMetrics.directTileWidth
: WindowSwitcherOverlayMetrics.keyWindowTileWidth
}
private var tileHeight: CGFloat {
model.presentation == .direct
? WindowSwitcherOverlayMetrics.directTileHeight
: WindowSwitcherOverlayMetrics.keyWindowTileHeight
}
private var spacing: CGFloat {
WindowSwitcherOverlayMetrics.horizontalSpacing
}
private var contentWidth: CGFloat {
let columns = max(1, model.columnCount)
let gridWidth = CGFloat(columns) * tileWidth + CGFloat(max(0, columns - 1)) * spacing
return max(220, gridWidth)
}
var body: some View {
VStack(spacing: WindowSwitcherOverlayMetrics.verticalSpacing) {
if model.entries.isEmpty {
emptyState
} else {
appGrid
}
}
.frame(width: contentWidth)
.padding(.horizontal, WindowSwitcherOverlayMetrics.panelHorizontalPadding)
.padding(.vertical, WindowSwitcherOverlayMetrics.panelVerticalPadding)
.background {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
guard model.recordingEntryID != nil else {
return
}
onCancelShortcutRecording()
}
}
.fixedSize()
}
private var appGrid: some View {
LazyVGrid(
columns: Array(
repeating: GridItem(.fixed(tileWidth), spacing: spacing),
count: max(1, model.columnCount)
),
spacing: WindowSwitcherOverlayMetrics.verticalSpacing
) {
ForEach(model.entries) { entry in
WindowSwitcherAppTile(
entry: entry,
isSelected: entry.id == model.selectedID,
isRecording: entry.id == model.recordingEntryID,
recordingCandidate: entry.id == model.recordingEntryID
? model.recordingCandidate
: nil,
recordingHasConflict: entry.id == model.recordingEntryID
&& model.recordingHasConflict,
showsShortcut: model.presentation == .keyWindow,
tileWidth: tileWidth,
tileHeight: tileHeight,
onSelect: { onSelect(entry) },
onQuit: { onQuit(entry) },
onRecordShortcut: { onRecordShortcut(entry) }
)
}
}
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "app.dashed")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(.secondary)
Text("没有可切换的窗口")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
}
.frame(height: 110)
.frame(maxWidth: .infinity)
}
}
private struct WindowSwitcherAppTile: View {
let entry: WindowSwitcherAppEntry
let isSelected: Bool
let isRecording: Bool
let recordingCandidate: String?
let recordingHasConflict: Bool
let showsShortcut: Bool
let tileWidth: CGFloat
let tileHeight: CGFloat
let onSelect: () -> Void
let onQuit: () -> Void
let onRecordShortcut: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var isIconHovered = false
@State private var isQuitHovered = false
@State private var isShortcutHovered = false
var body: some View {
ZStack(alignment: .topLeading) {
VStack(spacing: showsShortcut ? WindowSwitcherOverlayMetrics.shortcutContentSpacing : 0) {
if showsShortcut {
Button(action: onRecordShortcut) {
shortcutBadge
.frame(height: WindowSwitcherOverlayMetrics.shortcutHeight)
}
.buttonStyle(.plain)
.onHover { isShortcutHovered = $0 }
.help("修改 \(entry.appName)快捷键")
} else {
Color.clear
.frame(height: WindowSwitcherOverlayMetrics.directTopSpacing)
}
Button(action: onSelect) {
tileContent
}
.buttonStyle(.plain)
}
.frame(width: tileWidth, height: tileHeight, alignment: .top)
quitButton
.position(quitButtonCenter)
}
.frame(width: tileWidth, height: tileHeight)
}
private var shortcutBadge: some View {
Group {
if isRecording, recordingCandidate == nil {
Image(systemName: "keyboard")
.font(.system(size: 10, weight: .semibold))
} else {
Text(recordingDisplay ?? entry.shortcutDisplay ?? "")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.lineLimit(1)
}
}
.foregroundStyle(shortcutForegroundColor)
.frame(minWidth: 22)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule(style: .continuous)
.fill(shortcutBackgroundColor)
)
.contentShape(Capsule(style: .continuous))
.animation(.easeOut(duration: 0.12), value: isRecording)
.animation(.easeOut(duration: 0.12), value: recordingHasConflict)
.animation(.easeOut(duration: 0.12), value: isShortcutHovered)
}
private var shortcutForegroundColor: Color {
if recordingHasConflict {
return Color(nsColor: .systemRed)
}
if isRecording {
return Color.accentColor
}
return .secondary
}
private var shortcutBackgroundColor: Color {
if recordingHasConflict {
return Color(nsColor: .systemRed).opacity(0.16)
}
if isRecording {
return Color.accentColor.opacity(0.16)
}
if isShortcutHovered {
return Color.primary.opacity(0.12)
}
return Color.primary.opacity(0.07)
}
private var recordingDisplay: String? {
guard isRecording, let recordingCandidate else {
return nil
}
return WindowSwitcherSelectionShortcut(storageValue: recordingCandidate)?.displayValue
}
private var quitButton: some View {
Button(action: onQuit) {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(quitForegroundColor)
.frame(
width: WindowSwitcherOverlayMetrics.quitButtonSize,
height: WindowSwitcherOverlayMetrics.quitButtonSize
)
.background(
Circle()
.fill(quitBackgroundColor)
)
.contentShape(Circle())
}
.buttonStyle(.plain)
.opacity(showsQuitButton ? 1 : 0)
.allowsHitTesting(showsQuitButton)
.onHover { isQuitHovered = $0 }
.help("退出 \(entry.appName)")
.animation(.easeOut(duration: 0.12), value: showsQuitButton)
.animation(.easeOut(duration: 0.12), value: isQuitHovered)
}
private var tileContent: some View {
VStack(spacing: WindowSwitcherOverlayMetrics.iconLabelSpacing) {
highlightedIcon
VStack(spacing: 1) {
Text(entry.displayName)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(.primary)
.frame(height: 14)
if let subtitle = entry.displaySubtitle {
Text(subtitle)
.font(.system(size: 10, weight: .regular))
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(.secondary)
.frame(height: 12)
} else {
Color.clear
.frame(height: 12)
}
}
.frame(
width: tileWidth - 12,
height: WindowSwitcherOverlayMetrics.labelAreaHeight,
alignment: .top
)
}
.frame(width: tileWidth, height: highlightedContentHeight, alignment: .top)
.help(accessibilityTitle)
}
private var highlightedIcon: some View {
ZStack {
RoundedRectangle(
cornerRadius: WindowSwitcherOverlayMetrics.iconHighlightCornerRadius,
style: .continuous
)
.fill(iconBackgroundColor)
iconView
}
.frame(
width: WindowSwitcherOverlayMetrics.iconHighlightSize,
height: WindowSwitcherOverlayMetrics.iconHighlightSize
)
.frame(
width: WindowSwitcherOverlayMetrics.iconLayoutSize,
height: WindowSwitcherOverlayMetrics.iconLayoutSize
)
.contentShape(Rectangle())
.onHover { isIconHovered = $0 }
.animation(.easeOut(duration: 0.12), value: isIconHovered)
.animation(.easeOut(duration: 0.12), value: isSelected)
}
private var iconBackgroundColor: Color {
if isSelected {
return Color.accentColor.opacity(0.16)
}
if showsShortcut && (isIconHovered || isQuitHovered) {
return Color.black.opacity(colorScheme == .dark ? 0.16 : 0.08)
}
return .clear
}
@ViewBuilder
private var iconView: some View {
if let icon = entry.icon {
Image(nsImage: icon)
.resizable()
.interpolation(.high)
.scaledToFit()
.frame(
width: WindowSwitcherOverlayMetrics.iconSize,
height: WindowSwitcherOverlayMetrics.iconSize
)
} else {
Image(systemName: "app.fill")
.font(.system(size: 52))
.foregroundStyle(.secondary)
.frame(
width: WindowSwitcherOverlayMetrics.iconSize,
height: WindowSwitcherOverlayMetrics.iconSize
)
}
}
private var highlightedContentTopOffset: CGFloat {
showsShortcut
? WindowSwitcherOverlayMetrics.shortcutHeight
+ WindowSwitcherOverlayMetrics.shortcutContentSpacing
: WindowSwitcherOverlayMetrics.directTopSpacing
}
private var quitButtonCenter: CGPoint {
WindowSwitcherOverlayMetrics.quitButtonCenter(
tileWidth: tileWidth,
contentTopOffset: highlightedContentTopOffset
)
}
private var highlightedContentHeight: CGFloat {
WindowSwitcherOverlayMetrics.tileContentHeight
}
private var showsQuitButton: Bool {
isIconHovered || isQuitHovered
}
private var quitForegroundColor: Color {
isQuitHovered ? Color(nsColor: .systemRed) : .secondary
}
private var quitBackgroundColor: Color {
isQuitHovered ? Color(nsColor: .systemRed).opacity(0.16) : Color.primary.opacity(0.08)
}
private var accessibilityTitle: String {
if let subtitle = entry.displaySubtitle {
return "\(entry.displayName)\(subtitle)"
}
return entry.displayName
}
}