beatmatchr/Desktop/YabaiPro/Sources/WindowAnimationManager.swift
BusyBee3333 7694d965c9 feat: Add structured signal editor with app dropdown and action builder
- Add AppDiscovery provider for running app enumeration
- Implement AppDropdownView with auto-launch functionality
- Create SignalAction models for 40+ yabai commands
- Build ActionBuilderView with nested parameter controls
- Add LiveShellPreview for real-time shell command generation
- Implement ActionValidator for conflict detection
- Add migration parser for existing raw action strings
- Include feature flag for safe rollout
- Maintain full backward compatibility
2025-12-31 01:44:13 -05:00

682 lines
24 KiB
Swift

//
// WindowAnimationManager.swift
// YabaiPro
//
// Created by Jake Shore
// Copyright © 2024 Jake Shore. All rights reserved.
//
import SwiftUI
import Combine
import MetalKit
class WindowAnimationManager: ObservableObject {
static let shared = WindowAnimationManager()
@Published var windowStates: [UInt32: WindowAnimationState] = [:]
@Published var globalAnimationTime: Double = 0.0
@Published var isMonitoringActive = false
// UI-controlled Metal animation settings
private var metalAnimationsEnabled = false
private var directWindowMetalEnabled = false
private let commandRunner = YabaiCommandRunner.shared
private var monitoringTask: Task<Void, Never>?
private var animationTimer: Timer?
private var cancellables = Set<AnyCancellable>()
private let overlayManager = WindowOverlayManager.shared
private let metalBinder = WindowMetalBinder.shared
private var eventTask: Task<Void, Never>?
private init() {
setupAnimationTimer()
startMonitoring()
startEventSubscription()
}
// Method to update Metal animation settings from UI
func updateMetalSettings(metalAnimationsEnabled: Bool, directWindowMetalEnabled: Bool) {
self.metalAnimationsEnabled = metalAnimationsEnabled
self.directWindowMetalEnabled = directWindowMetalEnabled
}
deinit {
stopMonitoring()
animationTimer?.invalidate()
overlayManager.hideAllAnimationOverlays()
eventTask?.cancel()
}
private func setupAnimationTimer() {
animationTimer = Timer.scheduledTimer(withTimeInterval: 1/60.0, repeats: true) { [weak self] _ in
self?.globalAnimationTime += 1/60.0
}
}
func startMonitoring() {
guard !isMonitoringActive else { return }
isMonitoringActive = true
monitoringTask = Task { [weak self] in
while !Task.isCancelled {
await self?.updateWindowStates()
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
}
}
}
func stopMonitoring() {
isMonitoringActive = false
monitoringTask?.cancel()
monitoringTask = nil
}
private func startEventSubscription() {
eventTask = Task {
let eventStream = YabaiCommandRunner.shared.subscribeToYabaiEvents()
do {
for try await event in eventStream {
await handleYabaiEvent(event)
}
} catch {
print("Error in yabai event subscription: \(error)")
}
}
}
private func handleYabaiEvent(_ event: YabaiEvent) async {
guard let windowId = event.windowId else { return }
// Get animation settings
let settings = AnimationPerformanceManager.shared.optimalSettings(
metalAnimationsEnabled: metalAnimationsEnabled,
directWindowMetalEnabled: directWindowMetalEnabled
)
// Only proceed if Metal animations are enabled
guard settings.metalEffectsEnabled else { return }
switch event.eventType {
case .windowFocus:
// Start focus animation on the real window
if let windowState = windowStates[windowId] {
do {
try await metalBinder.attachMetalLayer(
to: windowId,
frame: windowState.frame,
animationType: .liquidBorder
)
// Get yabai animation duration and start Metal animation
if let duration = await getYabaiAnimationDuration(),
let easing = await getYabaiAnimationEasing() {
metalBinder.startAnimation(on: windowId, duration: duration, easing: easing)
}
} catch {
print("Failed to attach Metal animation to window \(windowId): \(error)")
// Fallback to overlay
overlayManager.showAnimationOverlay(for: windowId, state: windowState, settings: settings)
}
}
case .windowMove, .windowResize:
// Update layer position/size for ongoing animations
if let windowState = windowStates[windowId] {
metalBinder.updateLayerFrame(for: windowId, frame: windowState.frame)
overlayManager.updateAnimationOverlay(for: windowId, state: windowState, settings: settings)
}
case .windowDestroy:
// Clean up Metal layer and overlay
metalBinder.detachMetalLayer(from: windowId)
overlayManager.hideAnimationOverlay(for: windowId)
default:
break
}
}
private func getYabaiAnimationDuration() async -> TimeInterval? {
do {
let configData = try await commandRunner.queryJSON("config")
if let config = try? JSONDecoder().decode([String: String].self, from: configData),
let durationStr = config["window_animation_duration"],
let duration = Double(durationStr) {
return duration
}
} catch {
print("Failed to get yabai animation duration: \(error)")
}
return 0.2 // Default
}
private func getYabaiAnimationEasing() async -> AnimationEasing? {
do {
let configData = try await commandRunner.queryJSON("config")
if let config = try? JSONDecoder().decode([String: String].self, from: configData),
let easingStr = config["window_animation_easing"] {
return AnimationEasing(rawValue: easingStr)
}
} catch {
print("Failed to get yabai animation easing: \(error)")
}
return .easeOutCubic // Default
}
private func updateWindowStates() async {
do {
// Get current window data from yabai
let windowsData = try await commandRunner.queryWindows()
let spacesData = try await commandRunner.querySpaces()
// Parse the data
let windows = try parseWindows(from: windowsData)
_ = try parseSpaces(from: spacesData) // Parse spaces for future use
// Update window states on main thread
await MainActor.run {
updateAnimationStates(for: windows)
}
} catch {
print("Failed to update window animation states: \(error)")
}
}
private func parseWindows(from data: Data) throws -> [YabaiWindow] {
let windows = try JSONDecoder().decode([YabaiWindowJSON].self, from: data)
return windows.map { json in
YabaiWindow(
id: json.id,
frame: CGRect(x: json.frame.x, y: json.frame.y, width: json.frame.w, height: json.frame.h),
hasFocus: json.hasFocus
)
}
}
private func parseSpaces(from data: Data) throws -> [YabaiSpace] {
let spaces = try JSONDecoder().decode([YabaiSpaceJSON].self, from: data)
return spaces.map { json in
YabaiSpace(
index: json.index,
focused: json.hasFocus ? json.firstWindow : nil
)
}
}
private func updateAnimationStates(for windows: [YabaiWindow]) {
var updatedStates = [UInt32: WindowAnimationState]()
for window in windows {
let wasFocused = windowStates[window.id]?.isFocused ?? false
let isNowFocused = window.hasFocus
// Create or update animation state
if let existingState = windowStates[window.id] {
var state = existingState // Create explicit mutable copy
// Update existing state
if !wasFocused && isNowFocused {
// Window just gained focus
state.isFocused = true
state.focusStartTime = Date()
state.morphProgress = 0.0
state.focusPoint = CGPoint(x: window.frame.midX, y: window.frame.midY)
// Animate morph progress
withAnimation(.easeOut(duration: 0.5)) {
state.morphProgress = 1.0
}
} else if wasFocused && !isNowFocused {
// Window lost focus
state.isFocused = false
state.focusLostTime = Date()
}
updatedStates[window.id] = state
} else {
// Create new state
let newState = WindowAnimationState(
windowId: window.id,
isFocused: isNowFocused,
frame: window.frame,
focusStartTime: isNowFocused ? Date() : nil,
focusPoint: isNowFocused ? CGPoint(x: window.frame.midX, y: window.frame.midY) : nil,
morphProgress: isNowFocused ? 0.0 : 1.0
)
if isNowFocused {
// Animate new focused window
withAnimation(.easeOut(duration: 0.5)) {
newState.morphProgress = 1.0
}
}
updatedStates[window.id] = newState
}
}
// Remove states for windows that no longer exist
let currentWindowIds = Set(windows.map { $0.id })
let removedWindowIds = Set(windowStates.keys).subtracting(currentWindowIds)
// Hide overlays for removed windows
for windowId in removedWindowIds {
overlayManager.hideAnimationOverlay(for: windowId)
}
windowStates = updatedStates.filter { currentWindowIds.contains($0.key) }
// Update overlays for all current windows
let settings = AnimationPerformanceManager.shared.optimalSettings(
metalAnimationsEnabled: metalAnimationsEnabled,
directWindowMetalEnabled: directWindowMetalEnabled
)
for (windowId, state) in windowStates {
if state.isFocused || state.morphProgress > 0 {
overlayManager.updateAnimationOverlay(for: windowId, state: state, settings: settings)
} else {
overlayManager.hideAnimationOverlay(for: windowId)
}
}
}
func getAnimationView(for windowId: UInt32) -> AnyView {
guard let state = windowStates[windowId] else {
return AnyView(EmptyView())
}
let settings = AnimationPerformanceManager.shared.optimalSettings(
metalAnimationsEnabled: metalAnimationsEnabled,
directWindowMetalEnabled: directWindowMetalEnabled
)
return AnyView(
WindowAnimationOverlay(
state: state,
settings: settings,
globalTime: globalAnimationTime
)
.environmentObject(self)
)
}
func handleWindowInteraction(windowId: UInt32, point: CGPoint, interactionType: InteractionType) {
guard let existingState = windowStates[windowId] else { return }
var state = existingState // Create explicit mutable copy
state.lastInteractionTime = Date()
state.focusPoint = point
state.lastInteractionType = interactionType
windowStates[windowId] = state
// Trigger interaction-based animations
switch interactionType {
case .click:
triggerRippleAnimation(for: windowId, at: point)
case .hover:
triggerHoverAnimation(for: windowId, at: point)
case .focus:
triggerFocusAnimation(for: windowId)
case .drag:
// Handle drag interactions
break
}
}
private func triggerRippleAnimation(for windowId: UInt32, at point: CGPoint) {
// Implementation for ripple effects
print("Triggering ripple animation for window \(windowId) at \(point)")
}
private func triggerHoverAnimation(for windowId: UInt32, at point: CGPoint) {
guard let existingState = windowStates[windowId] else { return }
var state = existingState
// Check if hover magnification is enabled
let hoverEnabled = ComprehensiveSettingsViewModel.shared.hoverMagnification
guard hoverEnabled else { return }
// Don't magnify if window is already magnified or focused
if state.isMagnified || state.isFocused { return }
// Apply magnification transformation
let factor = ComprehensiveSettingsViewModel.shared.hoverMagnificationFactor
let originalFrame = state.originalFrame ?? state.frame
// Calculate magnified frame (centered on cursor if possible)
let magnifiedWidth = originalFrame.width * CGFloat(factor)
let magnifiedHeight = originalFrame.height * CGFloat(factor)
// Center the magnification around the cursor point
let offsetX = (magnifiedWidth - originalFrame.width) / 2.0
let offsetY = (magnifiedHeight - originalFrame.height) / 2.0
let magnifiedFrame = CGRect(
x: originalFrame.minX - offsetX,
y: originalFrame.minY - offsetY,
width: magnifiedWidth,
height: magnifiedHeight
)
state.frame = magnifiedFrame
state.isMagnified = true
state.magnificationPoint = point
// Animate the change
let duration = ComprehensiveSettingsViewModel.shared.hoverMagnificationDuration
withAnimation(.easeOut(duration: duration)) {
windowStates[windowId] = state
}
// Update Metal layer if it exists
metalBinder.updateLayerFrame(for: windowId, frame: magnifiedFrame)
print("🔍 Magnified window \(windowId) to \(factor)x at \(point)")
}
func handleHoverEnd(windowId: UInt32) {
guard var state = windowStates[windowId], state.isMagnified else { return }
// Restore original frame
if let originalFrame = state.originalFrame {
state.frame = originalFrame
state.isMagnified = false
state.magnificationPoint = nil
// Animate back to original size
let duration = ComprehensiveSettingsViewModel.shared.hoverMagnificationDuration
withAnimation(.easeOut(duration: duration)) {
windowStates[windowId] = state
}
// Update Metal layer
metalBinder.updateLayerFrame(for: windowId, frame: originalFrame)
print("🔍 Unmagnified window \(windowId)")
}
}
private func triggerFocusAnimation(for windowId: UInt32) {
// Implementation for focus effects
print("Triggering focus animation for window \(windowId)")
}
// MARK: - Animation State Queries
func getFocusedWindows() -> [WindowAnimationState] {
return windowStates.values.filter { $0.isFocused }
}
func getWindowsNeedingAnimation() -> [WindowAnimationState] {
return windowStates.values.filter { state in
// Windows that are focused or recently lost focus
state.isFocused ||
(state.focusLostTime != nil && Date().timeIntervalSince(state.focusLostTime!) < 2.0)
}
}
func resetAllAnimations() {
for (windowId, existingState) in windowStates {
var state = existingState // Create explicit mutable copy
state.morphProgress = 0.0
state.focusStartTime = nil
state.focusLostTime = nil
state.isFocused = false
windowStates[windowId] = state
}
}
// MARK: - Debug/Testing Functions
/// Creates test window states for debugging animations
func createTestWindowStates() {
print("🎭 Creating test window states for animation debugging...")
// Clear existing states first
let oldCount = windowStates.count
windowStates.removeAll()
if oldCount > 0 {
print("🧹 Cleared \(oldCount) existing window states")
}
let testFrames = [
CGRect(x: 100, y: 100, width: 800, height: 600),
CGRect(x: 950, y: 100, width: 800, height: 600),
CGRect(x: 100, y: 750, width: 800, height: 600)
]
for (index, frame) in testFrames.enumerated() {
let windowId = UInt32(index + 1)
let isFocused = (index == 0) // First window is focused
let state = WindowAnimationState(
windowId: windowId,
isFocused: isFocused,
frame: frame,
focusStartTime: isFocused ? Date() : nil,
focusPoint: isFocused ? CGPoint(x: frame.midX, y: frame.midY) : nil,
morphProgress: isFocused ? 1.0 : 0.0
)
windowStates[windowId] = state
print("✅ Created test window \(windowId): focused=\(isFocused), frame=\(frame)")
}
print("🎯 Test setup complete!")
print("💡 Metal: \(MetalAnimationEngine.shared.isMetalAvailable ? "✅ Available" : "❌ Unavailable")")
print("💡 Windows created: \(windowStates.count)")
print("💡 Windows needing animation: \(getWindowsNeedingAnimation().count)")
print("💡 Animation timer running: \(animationTimer != nil)")
print("🔄 Animation time: \(globalAnimationTime)")
// Force objectWillChange to trigger UI updates
objectWillChange.send()
}
/// Debug function to print current window states
func debugPrintWindowStates() {
print("🔍 Current Window States:")
for (windowId, state) in windowStates {
print(" Window \(windowId): focused=\(state.isFocused), morph=\(state.morphProgress)")
}
print("🎨 Total windows with animation states: \(windowStates.count)")
}
}
// MARK: - Supporting Types
// AnimationWindowInfo, InteractionType are defined in AnimationTypes.swift
class WindowAnimationState: ObservableObject {
let windowId: UInt32
@Published var isFocused: Bool
@Published var frame: CGRect
@Published var focusStartTime: Date?
@Published var focusLostTime: Date?
@Published var focusPoint: CGPoint?
@Published var morphProgress: Double = 0.0
@Published var lastInteractionTime: Date?
@Published var lastInteractionType: InteractionType?
// Hover magnification properties
@Published var originalFrame: CGRect? // Store original frame before magnification
@Published var isMagnified = false
@Published var magnificationPoint: CGPoint?
init(windowId: UInt32, isFocused: Bool, frame: CGRect, focusStartTime: Date? = nil, focusPoint: CGPoint? = nil, morphProgress: Double = 0.0) {
self.windowId = windowId
self.isFocused = isFocused
self.frame = frame
self.focusStartTime = focusStartTime
self.focusPoint = focusPoint
self.morphProgress = morphProgress
self.originalFrame = frame // Store original frame for magnification restore
}
var timeSinceFocus: Double {
guard let startTime = focusStartTime else { return 0 }
return Date().timeIntervalSince(startTime)
}
var timeSinceInteraction: Double {
guard let interactionTime = lastInteractionTime else { return Double.greatestFiniteMagnitude }
return Date().timeIntervalSince(interactionTime)
}
}
// InteractionType is defined in AnimationTypes.swift
// YabaiWindowJSON is now defined in YabaiCommandRunner.swift
private struct YabaiSpaceJSON: Codable {
let index: UInt32
let hasFocus: Bool
let firstWindow: UInt32?
enum CodingKeys: String, CodingKey {
case index
case hasFocus = "has-focus"
case firstWindow = "first-window"
}
}
struct YabaiWindow {
let id: UInt32
let frame: CGRect
let hasFocus: Bool
}
struct YabaiSpace {
let index: UInt32
let focused: UInt32?
}
// MARK: - Animation Overlay View
struct WindowAnimationOverlay: View {
@ObservedObject var state: WindowAnimationState
let settings: AnimationSettings
let globalTime: Double
var body: some View {
ZStack {
// Metal-based animations (if enabled and available)
if settings.metalEffectsEnabled && MetalAnimationEngine.shared.isMetalAvailable {
MetalAnimationView(
animationType: .liquidBorder,
windowInfo: AnimationWindowInfo(
id: state.windowId,
isFocused: state.isFocused,
focusPoint: state.focusPoint,
morphProgress: state.morphProgress
),
time: .constant(globalTime)
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.blendMode(.screen)
.opacity(settings.metalEffectsEnabled ? 0.8 : 0.0)
// Additional particle effects for focused windows
if state.isFocused && settings.particleCount > 0 {
MetalAnimationView(
animationType: .flowingParticles,
windowInfo: AnimationWindowInfo(
id: state.windowId,
isFocused: true,
focusPoint: state.focusPoint,
morphProgress: state.morphProgress
),
time: .constant(globalTime)
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.blendMode(.screen)
.opacity(0.6)
}
} else {
// Canvas fallback
CanvasFallbackOverlay(state: state, settings: settings, globalTime: globalTime)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.allowsHitTesting(false) // Don't interfere with window interactions
}
}
// MARK: - Canvas Fallback
struct CanvasFallbackOverlay: View {
let state: WindowAnimationState
let settings: AnimationSettings
let globalTime: Double
var body: some View {
Canvas { context, size in
let time = Float(globalTime)
if state.isFocused {
// Draw liquid border effect
drawLiquidBorder(in: context, size: size, time: time, isActive: true)
}
if state.isFocused && settings.particleCount > 0 {
// Draw simple particle effects
drawParticles(in: context, size: size, time: time, count: settings.particleCount)
}
}
}
private func drawLiquidBorder(in context: GraphicsContext, size: CGSize, time: Float, isActive: Bool) {
let amplitude = isActive ? 4.0 : 1.0
let segments = 32
let borderWidth: CGFloat = 3.0
var path = Path()
for i in 0...segments {
let t = Double(i) / Double(segments)
let x = t * size.width
let baseY: CGFloat = 0
// Create flowing wave
let wave = CGFloat(sin(t * 6.28 + Double(time) * 2.0) * amplitude)
let point = CGPoint(x: x, y: baseY + wave)
if i == 0 {
path.move(to: point)
} else {
path.addLine(to: point)
}
}
// Close the border path
path.addLine(to: CGPoint(x: size.width, y: size.height))
path.addLine(to: CGPoint(x: 0, y: size.height))
path.closeSubpath()
context.stroke(path, with: .color(Color.blue.opacity(0.6)), lineWidth: borderWidth)
}
private func drawParticles(in context: GraphicsContext, size: CGSize, time: Float, count: Int) {
for i in 0..<min(count, 15) {
let t = Float(i) / Float(count)
let x = cos(time * 0.5 + t * 6.28) * Float(size.width) * 0.3 + Float(size.width) * 0.5
let y = sin(time * 0.7 + t * 6.28) * Float(size.height) * 0.3 + Float(size.height) * 0.5
let particleSize: CGFloat = 4.0 + CGFloat(sin(time * 2 + t * 6.28)) * 2.0
let rect = CGRect(x: CGFloat(x) - particleSize/2,
y: CGFloat(y) - particleSize/2,
width: particleSize,
height: particleSize)
context.fill(Path(ellipseIn: rect), with: .color(Color.blue.opacity(0.4)))
}
}
}