vibetunnel/ios/VibeTunnel/Utils/MacCatalystWindow.swift

298 lines
11 KiB
Swift

import SwiftUI
#if targetEnvironment(macCatalyst)
import Dynamic
import UIKit
// MARK: - Window Style
enum MacWindowStyle {
case standard // Normal title bar with traffic lights
case inline // Hidden title bar with repositioned traffic lights
}
// MARK: - UIWindow Extension
extension UIWindow {
/// Access the underlying NSWindow in Mac Catalyst
var nsWindow: NSObject? {
var nsWindow = Dynamic.NSApplication.sharedApplication.delegate.hostWindowForUIWindow(self)
nsWindow = nsWindow.attachedWindow
return nsWindow.asObject
}
}
// MARK: - Window Manager
@MainActor
class MacCatalystWindowManager: ObservableObject {
static let shared = MacCatalystWindowManager()
@Published var windowStyle: MacWindowStyle = .standard
private var window: UIWindow?
private var windowResizeObserver: NSObjectProtocol?
private var windowDidBecomeKeyObserver: NSObjectProtocol?
private let logger = Logger(category: "MacCatalystWindow")
// Traffic light button configuration
private let trafficLightInset = CGPoint(x: 20, y: 20)
private let trafficLightSpacing: CGFloat = 20
private init() {}
/// Configure the window with the specified style
func configureWindow(_ window: UIWindow, style: MacWindowStyle) {
self.window = window
self.windowStyle = style
// Wait for window to be fully initialized
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
self.applyWindowStyle(style)
}
// Observe window events
setupWindowObservers()
}
/// Switch between window styles at runtime
func setWindowStyle(_ style: MacWindowStyle) {
windowStyle = style
applyWindowStyle(style)
}
private func applyWindowStyle(_ style: MacWindowStyle) {
guard let window,
let nsWindow = window.nsWindow
else {
logger.warning("Unable to access NSWindow")
return
}
let dynamic = Dynamic(nsWindow)
switch style {
case .standard:
applyStandardStyle(dynamic)
case .inline:
applyInlineStyle(dynamic, window: window)
}
}
private func applyStandardStyle(_ nsWindow: Dynamic) {
logger.info("Applying standard window style")
// Show title bar
nsWindow.titlebarAppearsTransparent = false
nsWindow.titleVisibility = Dynamic.NSWindowTitleVisibility.visible
guard let currentMask = nsWindow.styleMask.asObject as? UInt,
let titledMask = Dynamic.NSWindowStyleMask.titled.asObject as? UInt
else {
logger.error("Failed to get window style masks")
return
}
nsWindow.styleMask = currentMask | titledMask
// Reset traffic light positions
resetTrafficLightPositions(nsWindow)
// Show all buttons
for i in 0...2 {
let button = nsWindow.standardWindowButton(i)
button.isHidden = false
}
}
private func applyInlineStyle(_ nsWindow: Dynamic, window: UIWindow) {
logger.info("Applying inline window style")
// Make title bar transparent and hide title
nsWindow.titlebarAppearsTransparent = true
nsWindow.titleVisibility = Dynamic.NSWindowTitleVisibility.hidden
nsWindow.backgroundColor = Dynamic.NSColor.clearColor
// Keep the titled style mask to preserve traffic lights
guard let currentMask = nsWindow.styleMask.asObject as? UInt,
let titledMask = Dynamic.NSWindowStyleMask.titled.asObject as? UInt
else {
logger.error("Failed to get window style masks")
return
}
nsWindow.styleMask = currentMask | titledMask
// Reposition traffic lights
repositionTrafficLights(nsWindow, window: window)
}
private func repositionTrafficLights(_ nsWindow: Dynamic, window: UIWindow) {
// Access the buttons (0=close, 1=minimize, 2=zoom)
let closeButton = nsWindow.standardWindowButton(0)
let minButton = nsWindow.standardWindowButton(1)
let zoomButton = nsWindow.standardWindowButton(2)
// Get button size
let buttonFrame = closeButton.frame
let buttonSize = (buttonFrame.size.width.asDouble ?? 14.0) as CGFloat
// Calculate positions
let yPosition = window.frame.height - trafficLightInset.y - buttonSize
// Set new positions
closeButton.setFrameOrigin(Dynamic.NSMakePoint(trafficLightInset.x, yPosition))
minButton.setFrameOrigin(Dynamic.NSMakePoint(trafficLightInset.x + trafficLightSpacing, yPosition))
zoomButton.setFrameOrigin(Dynamic.NSMakePoint(trafficLightInset.x + (trafficLightSpacing * 2), yPosition))
// Make sure buttons are visible
closeButton.isHidden = false
minButton.isHidden = false
zoomButton.isHidden = false
// Update tracking areas for hover effects
updateTrafficLightTrackingAreas(nsWindow)
logger.debug("Repositioned traffic lights to inline positions")
}
private func resetTrafficLightPositions(_ nsWindow: Dynamic) {
// Get the superview of the traffic lights
let closeButton = nsWindow.standardWindowButton(0)
if let superview = closeButton.superview {
// Force layout update to reset positions
superview.setNeedsLayout?.asObject = true
superview.layoutIfNeeded()
}
}
private func updateTrafficLightTrackingAreas(_ nsWindow: Dynamic) {
// Update tracking areas for each button to ensure hover effects work
for i in 0...2 {
let button = nsWindow.standardWindowButton(i)
// Remove old tracking areas
if let trackingAreas = button.trackingAreas {
for area in trackingAreas.asArray ?? [] {
button.removeTrackingArea(area)
}
}
// Add new tracking area at the button's current position
let trackingRect = button.bounds
guard let mouseEnteredAndExited = Dynamic.NSTrackingAreaOptions.mouseEnteredAndExited.asObject as? UInt,
let activeAlways = Dynamic.NSTrackingAreaOptions.activeAlways.asObject as? UInt
else {
logger.error("Failed to get tracking area options")
return
}
let options = mouseEnteredAndExited | activeAlways
let trackingArea = Dynamic.NSTrackingArea.alloc()
.initWithRect(trackingRect, options: options, owner: button, userInfo: nil)
button.addTrackingArea(trackingArea)
}
}
private func setupWindowObservers() {
// Clean up existing observers
if let observer = windowResizeObserver {
NotificationCenter.default.removeObserver(observer)
}
if let observer = windowDidBecomeKeyObserver {
NotificationCenter.default.removeObserver(observer)
}
// Observe window resize events
windowResizeObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("NSWindowDidResizeNotification"),
object: nil,
queue: .main
) { [weak self] notification in
guard let self,
self.windowStyle == .inline,
let window = self.window,
let notificationWindow = notification.object as? NSObject,
let currentNSWindow = window.nsWindow,
notificationWindow == currentNSWindow else { return }
// Reapply inline style on resize
DispatchQueue.main.async {
self.applyWindowStyle(.inline)
}
}
// Observe window becoming key
windowDidBecomeKeyObserver = NotificationCenter.default.addObserver(
forName: UIWindow.didBecomeKeyNotification,
object: window,
queue: .main
) { [weak self] _ in
guard let self,
self.windowStyle == .inline else { return }
// Reapply inline style when window becomes key
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.applyWindowStyle(.inline)
}
}
// Also observe the NS notification for tracking area updates
NotificationCenter.default.addObserver(
forName: NSNotification.Name("NSViewDidUpdateTrackingAreasNotification"),
object: nil,
queue: .main
) { [weak self] _ in
guard let self,
self.windowStyle == .inline else { return }
// Reposition if needed
if let window = self.window,
let nsWindow = window.nsWindow
{
self.repositionTrafficLights(Dynamic(nsWindow), window: window)
}
}
}
deinit {
if let observer = windowResizeObserver {
NotificationCenter.default.removeObserver(observer)
}
if let observer = windowDidBecomeKeyObserver {
NotificationCenter.default.removeObserver(observer)
}
}
}
// MARK: - View Modifier
struct MacCatalystWindowStyle: ViewModifier {
let style: MacWindowStyle
@StateObject private var windowManager = MacCatalystWindowManager.shared
func body(content: Content) -> some View {
content
.onAppear {
setupWindow()
}
}
private func setupWindow() {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first
else {
return
}
windowManager.configureWindow(window, style: style)
}
}
// MARK: - View Extension
extension View {
/// Configure the Mac Catalyst window style
func macCatalystWindowStyle(_ style: MacWindowStyle) -> some View {
modifier(MacCatalystWindowStyle(style: style))
}
}
#endif