mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-19 13:35:54 +00:00
298 lines
11 KiB
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
|