ggbond268--mactools
844 行
28 KiB
Swift
844 行
28 KiB
Swift
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
|
|
}
|
|
}
|