vibetunnel/mac/VibeTunnel/Core/Services/WindowTracking/WindowHighlightEffect.swift
Helmut Januschka f3b2022d48
Integrate screencap functionality for remote screen sharing (#209)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2025-07-06 03:31:34 +01:00

314 lines
10 KiB
Swift

import AppKit
import Foundation
import OSLog
/// Configuration for window highlight effects
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: "sh.vibetunnel.vibetunnel",
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()
}
}