/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #if os(macOS) import AppKit import SwiftUI import SkyLightWindow import QuartzCore import Defaults import Combine @MainActor final class VerticalHUDWindowManager { static let shared = VerticalHUDWindowManager() // Properties private var windows: [NSScreen: OSDWindow] = [:] private var hideWorkItem: DispatchWorkItem? private let displayDuration: TimeInterval = 2.0 private let animationDuration: TimeInterval = 0.2 private var cancellables = Set() private init() { setupPositionObserver() } // Helper Struct private struct OSDWindow { let nsWindow: NSWindow let hostingView: NSHostingView let state: VerticalHUDState } private func setupPositionObserver() { // Observe layout keys individually let updateBlock: (Any) -> Void = { [weak self] _ in Task { @MainActor [weak self] in self?.updateWindowLayout() } } Defaults.publisher(.verticalHUDPosition, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) Defaults.publisher(.verticalHUDHeight, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) Defaults.publisher(.verticalHUDWidth, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) Defaults.publisher(.verticalHUDPadding, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) Defaults.publisher(.verticalHUDInteractive, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) Defaults.publisher(.enableVerticalHUD, options: []).sink { [weak self] change in Task { @MainActor [weak self] in self?.handleEnablementChange(change.newValue) } }.store(in: &cancellables) } private func updateWindowLayout() { guard !windows.isEmpty else { return } for (screen, window) in windows { let screenFrame = screen.frame let position = Defaults[.verticalHUDPosition] let width = Defaults[.verticalHUDWidth] let height = Defaults[.verticalHUDHeight] let padding = Defaults[.verticalHUDPadding] // Add padding for shadow and elastic stretch // We use 120px total padding (60px per side) to absolutely guarantee no clipping. let shadowPadding: CGFloat = 120 let totalWidth = width + shadowPadding let totalHeight = height + shadowPadding let x: CGFloat if position == "left" { // Left edge of HUD is at (minX + padding). // Window edge = HUD Edge - half shadow padding x = screenFrame.minX + padding - (shadowPadding / 2) } else { // Right edge of HUD is at (maxX - padding). // Left edge of HUD = maxX - padding - width. // Window edge = HUD Edge - half shadow padding x = screenFrame.maxX - width - padding - (shadowPadding / 2) } let y = screenFrame.midY - (totalHeight / 2) let newFrame = NSRect(x: x, y: y, width: totalWidth, height: totalHeight) window.nsWindow.setFrame(newFrame, display: true, animate: true) applyInteractivity(window) } } private func handleEnablementChange(_ isEnabled: Bool) { if isEnabled { updateWindowLayout() } else { teardownWindows() } } private func applyInteractivity(_ window: OSDWindow, visibleOverride: Bool? = nil) { let isVisible = visibleOverride ?? (window.nsWindow.alphaValue > 0.01) let shouldAllowInteraction = Defaults[.enableVerticalHUD] && Defaults[.verticalHUDInteractive] && isVisible window.nsWindow.ignoresMouseEvents = !shouldAllowInteraction } private func updateWindowInteractivity() { for window in windows.values { applyInteractivity(window) } } private func teardownWindows() { hideWorkItem?.cancel() hideWorkItem = nil for window in windows.values { window.nsWindow.orderOut(nil) window.nsWindow.ignoresMouseEvents = true } windows.removeAll() } func show(type: SneakContentType, value: CGFloat, icon: String = "") { guard Defaults[.enableVerticalHUD] else { return } let screens = NSScreen.screens guard !screens.isEmpty else { return } // Show on all screens for screen in screens { let windowContext = ensureWindow(for: type, screen: screen) updateContent(window: windowContext, type: type, value: value, icon: icon) // Ensure visible windowContext.nsWindow.orderFrontRegardless() applyInteractivity(windowContext, visibleOverride: true) NSAnimationContext.runAnimationGroup { context in context.duration = animationDuration windowContext.nsWindow.animator().alphaValue = 1 } } scheduleHide() } func triggerBump(direction: Int) { guard Defaults[.enableVerticalHUD] else { return } Task { @MainActor in for window in windows.values { window.state.bumpEvent = BumpEvent(direction: direction, timestamp: Date()) } } } private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow { if let existing = windows[screen] { return existing } let state = VerticalHUDState(type: type, value: 0, icon: "") let osdView = VerticalHUDView(state: state) let hostingView = NSHostingView(rootView: osdView) // Calculate frame let screenFrame = screen.frame let position = Defaults[.verticalHUDPosition] let width = Defaults[.verticalHUDWidth] let height = Defaults[.verticalHUDHeight] let padding = Defaults[.verticalHUDPadding] // Add padding for shadow and elastic stretch // We use 120px total padding (60px per side) to absolutely guarantee no clipping. let shadowPadding: CGFloat = 120 let totalWidth = width + shadowPadding let totalHeight = height + shadowPadding let x: CGFloat if position == "left" { x = screenFrame.minX + padding - (shadowPadding / 2) } else { x = screenFrame.maxX - width - padding - (shadowPadding / 2) } // Center vertically let y = screenFrame.midY - (totalHeight / 2) let frame = NSRect(x: x, y: y, width: totalWidth, height: totalHeight) let win = NSWindow( contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) win.isOpaque = false win.backgroundColor = NSColor.clear win.level = NSWindow.Level.screenSaver win.collectionBehavior = [NSWindow.CollectionBehavior.canJoinAllSpaces, NSWindow.CollectionBehavior.stationary, NSWindow.CollectionBehavior.ignoresCycle, NSWindow.CollectionBehavior.fullScreenAuxiliary] win.hasShadow = false win.contentView = hostingView win.alphaValue = 0 // Default to ignoring events until explicitly shown win.ignoresMouseEvents = true SkyLightOperator.shared.delegateWindow(win) let windowStruct = OSDWindow(nsWindow: win, hostingView: hostingView, state: state) windows[screen] = windowStruct return windowStruct } private func updateContent(window: OSDWindow, type: SneakContentType, value: CGFloat, icon: String) { // Update state instead of replacing view window.state.type = type window.state.value = value window.state.icon = icon } private func scheduleHide() { hideWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in guard let self else { return } Task { @MainActor in self.hideWindow() } } hideWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + displayDuration, execute: workItem) } private func hideWindow() { for window in windows.values { NSAnimationContext.runAnimationGroup { context in context.duration = animationDuration window.nsWindow.animator().alphaValue = 0 } completionHandler: { [weak self] in self?.applyInteractivity(window, visibleOverride: false) } } } } #endif