import AppKit import Foundation import OSLog /// Configuration for window highlight effects. /// /// Defines the visual properties of the highlight effect applied to windows, /// including color, animation duration, border width, and glow intensity. struct WindowHighlightConfig { /// The color of the highlight border let color: NSColor /// Duration of the pulse animation in seconds let duration: TimeInterval /// Width of the border stroke let borderWidth: CGFloat /// Radius of the glow effect let glowRadius: CGFloat /// Whether the effect is enabled let isEnabled: Bool /// Default configuration with VibeTunnel branding static let `default` = Self( color: NSColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0), // Green to match frontend duration: 0.8, borderWidth: 4.0, glowRadius: 12.0, isEnabled: true ) /// A more subtle configuration static let subtle = Self( color: .systemBlue, duration: 0.5, borderWidth: 2.0, glowRadius: 6.0, isEnabled: true ) /// A vibrant neon-style configuration static let neon = Self( color: NSColor(red: 0.0, green: 1.0, blue: 0.8, alpha: 1.0), // Cyan duration: 1.2, borderWidth: 6.0, glowRadius: 20.0, isEnabled: true ) } /// Provides visual highlighting effects for terminal windows. /// Creates a border pulse/glow effect to make window selection more noticeable. @MainActor final class WindowHighlightEffect { private let logger = Logger( subsystem: BundleIdentifiers.loggerSubsystem, category: "WindowHighlightEffect" ) /// Active overlay windows for effects private var overlayWindows: [NSWindow] = [] /// Current configuration private var config: WindowHighlightConfig = .default /// Initialize with a specific configuration init(config: WindowHighlightConfig = .default) { self.config = config } /// Update the configuration func updateConfig(_ newConfig: WindowHighlightConfig) { self.config = newConfig } /// Converts screen coordinates (top-left origin) to Cocoa coordinates (bottom-left origin) /// This is necessary because: /// - Accessibility API returns coordinates with origin at screen top-left /// - NSWindow expects coordinates with origin at screen bottom-left /// - Multiple displays complicate this further private func convertScreenToCocoaCoordinates(_ screenFrame: CGRect) -> CGRect { // The key insight: NSScreen coordinates are ALREADY in Cocoa coordinates (bottom-left origin) // But the window bounds we get from Accessibility API are in screen coordinates (top-left origin) // First, we need to find the total screen height across all displays let screens = NSScreen.screens guard let mainScreen = NSScreen.main else { logger.error("No main screen found") return screenFrame } // Find which screen contains this window by checking in screen coordinates var targetScreen: NSScreen? let windowCenter = CGPoint(x: screenFrame.midX, y: screenFrame.midY) for screen in screens { // Convert screen's Cocoa frame to screen coordinates for comparison let screenFrameInScreenCoords = convertCocoaToScreenRect( screen.frame, mainScreenHeight: mainScreen.frame.height ) if screenFrameInScreenCoords.contains(windowCenter) { targetScreen = screen break } } // Use the screen we found, or main screen as fallback let screen = targetScreen ?? mainScreen logger.debug("Screen info for coordinate conversion:") logger .debug( " Target screen frame (Cocoa): x=\(screen.frame.origin.x), y=\(screen.frame.origin.y), w=\(screen.frame.width), h=\(screen.frame.height)" ) logger .debug( " Window frame (screen coords): x=\(screenFrame.origin.x), y=\(screenFrame.origin.y), w=\(screenFrame.width), h=\(screenFrame.height)" ) logger.debug(" Window center: x=\(windowCenter.x), y=\(windowCenter.y)") logger.debug(" Is main screen: \(screen == NSScreen.main)") // Convert window coordinates from screen (top-left) to Cocoa (bottom-left) // The key is that we need to use the main screen's height as reference let mainScreenHeight = mainScreen.frame.height // In screen coordinates, y=0 is at the top of the main screen // In Cocoa coordinates, y=0 is at the bottom of the main screen // So: cocoaY = mainScreenHeight - (screenY + windowHeight) let cocoaY = mainScreenHeight - (screenFrame.origin.y + screenFrame.height) return CGRect( x: screenFrame.origin.x, y: cocoaY, width: screenFrame.width, height: screenFrame.height ) } /// Helper to convert Cocoa rect to screen coordinates for comparison private func convertCocoaToScreenRect(_ cocoaRect: CGRect, mainScreenHeight: CGFloat) -> CGRect { // Convert from bottom-left origin to top-left origin let screenY = mainScreenHeight - (cocoaRect.origin.y + cocoaRect.height) return CGRect( x: cocoaRect.origin.x, y: screenY, width: cocoaRect.width, height: cocoaRect.height ) } /// Highlight a window with a border pulse effect func highlightWindow(_ window: AXElement, bounds: CGRect? = nil) { guard config.isEnabled else { return } let windowFrame: CGRect if let bounds { // Use provided bounds windowFrame = bounds } else { // Get window bounds using AXElement guard let frame = window.frame() else { logger.error("Failed to get window bounds for highlight effect") return } windowFrame = frame } // Convert from screen coordinates (top-left origin) to Cocoa coordinates (bottom-left origin) let cocoaFrame = convertScreenToCocoaCoordinates(windowFrame) logger.debug("Window highlight coordinate conversion:") logger .debug( " Original frame: x=\(windowFrame.origin.x), y=\(windowFrame.origin.y), w=\(windowFrame.width), h=\(windowFrame.height)" ) logger .debug( " Cocoa frame: x=\(cocoaFrame.origin.x), y=\(cocoaFrame.origin.y), w=\(cocoaFrame.width), h=\(cocoaFrame.height)" ) // Create overlay window let overlayWindow = createOverlayWindow( frame: cocoaFrame ) // Add to tracking overlayWindows.append(overlayWindow) // Show the window overlayWindow.orderFront(nil) // Animate the pulse effect animatePulse(on: overlayWindow, duration: config.duration) { [weak self] in Task { @MainActor in self?.removeOverlay(overlayWindow) } } } /// Create an overlay window for the effect private func createOverlayWindow(frame: CGRect) -> NSWindow { let window = NSWindow( contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false ) window.backgroundColor = .clear window.isOpaque = false window.level = .screenSaver window.ignoresMouseEvents = true window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] // Create custom view for the effect let viewBounds = window.contentView?.bounds ?? frame let effectView = BorderEffectView( frame: viewBounds, color: config.color, borderWidth: config.borderWidth, glowRadius: config.glowRadius ) effectView.autoresizingMask = [NSView.AutoresizingMask.width, NSView.AutoresizingMask.height] window.contentView = effectView return window } /// Animate the pulse effect private func animatePulse(on window: NSWindow, duration: TimeInterval, completion: @escaping @Sendable () -> Void) { guard let effectView = window.contentView as? BorderEffectView else { return } NSAnimationContext.runAnimationGroup { context in context.duration = duration context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) // Animate from full opacity to transparent effectView.animator().alphaValue = 0.0 } completionHandler: { completion() } } /// Remove an overlay window private func removeOverlay(_ window: NSWindow) { window.orderOut(nil) overlayWindows.removeAll { $0 == window } } /// Clean up all overlay windows func cleanup() { for window in overlayWindows { window.orderOut(nil) } overlayWindows.removeAll() } } /// Custom view for border effect private class BorderEffectView: NSView { private let borderColor: NSColor private let borderWidth: CGFloat private let glowRadius: CGFloat init(frame: NSRect, color: NSColor, borderWidth: CGFloat, glowRadius: CGFloat) { self.borderColor = color self.borderWidth = borderWidth self.glowRadius = glowRadius super.init(frame: frame) self.wantsLayer = true self.alphaValue = 1.0 } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) guard let context = NSGraphicsContext.current?.cgContext else { return } context.saveGState() // Create inset rect for border let borderRect = bounds.insetBy(dx: borderWidth / 2, dy: borderWidth / 2) let borderPath = NSBezierPath(roundedRect: borderRect, xRadius: 8, yRadius: 8) // Draw glow effect context.setShadow( offset: .zero, blur: glowRadius, color: borderColor.withAlphaComponent(0.8).cgColor ) // Draw border borderColor.setStroke() borderPath.lineWidth = borderWidth borderPath.stroke() // Draw inner glow context.setShadow( offset: .zero, blur: glowRadius / 2, color: borderColor.withAlphaComponent(0.4).cgColor ) borderPath.stroke() context.restoreGState() } }