244 lines
8.3 KiB
Swift
244 lines
8.3 KiB
Swift
//
|
|
// MenuBarPanelManager.swift
|
|
// leanring-buddy
|
|
//
|
|
// Manages the NSStatusItem (menu bar icon) and a custom borderless NSPanel
|
|
// that drops down below it when clicked. The panel hosts a SwiftUI view
|
|
// (CompanionPanelView) via NSHostingView. Uses the same NSPanel pattern as
|
|
// FloatingSessionButton and GlobalPushToTalkOverlay for consistency.
|
|
//
|
|
// The panel is non-activating so it does not steal focus from the user's
|
|
// current app, and auto-dismisses when the user clicks outside.
|
|
//
|
|
|
|
import AppKit
|
|
import SwiftUI
|
|
|
|
extension Notification.Name {
|
|
static let clickyDismissPanel = Notification.Name("clickyDismissPanel")
|
|
}
|
|
|
|
/// Custom NSPanel subclass that can become the key window even with
|
|
/// .nonactivatingPanel style, allowing text fields to receive focus.
|
|
private class KeyablePanel: NSPanel {
|
|
override var canBecomeKey: Bool { true }
|
|
}
|
|
|
|
@MainActor
|
|
final class MenuBarPanelManager: NSObject {
|
|
private var statusItem: NSStatusItem?
|
|
private var panel: NSPanel?
|
|
private var clickOutsideMonitor: Any?
|
|
private var dismissPanelObserver: NSObjectProtocol?
|
|
|
|
private let companionManager: CompanionManager
|
|
private let panelWidth: CGFloat = 320
|
|
private let panelHeight: CGFloat = 380
|
|
|
|
init(companionManager: CompanionManager) {
|
|
self.companionManager = companionManager
|
|
super.init()
|
|
createStatusItem()
|
|
|
|
dismissPanelObserver = NotificationCenter.default.addObserver(
|
|
forName: .clickyDismissPanel,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.hidePanel()
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
if let monitor = clickOutsideMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
}
|
|
if let observer = dismissPanelObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
}
|
|
}
|
|
|
|
// MARK: - Status Item
|
|
|
|
private func createStatusItem() {
|
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
|
|
guard let button = statusItem?.button else { return }
|
|
|
|
button.image = makeClickyMenuBarIcon()
|
|
button.image?.isTemplate = true
|
|
button.action = #selector(statusItemClicked)
|
|
button.target = self
|
|
}
|
|
|
|
/// Draws the clicky triangle as a menu bar icon. Uses the same shape
|
|
/// and rotation as the in-app cursor so the menu bar icon matches.
|
|
private func makeClickyMenuBarIcon() -> NSImage {
|
|
let iconSize: CGFloat = 18
|
|
let image = NSImage(size: NSSize(width: iconSize, height: iconSize))
|
|
image.lockFocus()
|
|
|
|
let triangleSize = iconSize * 0.7
|
|
let cx = iconSize * 0.50
|
|
let cy = iconSize * 0.50
|
|
let height = triangleSize * sqrt(3.0) / 2.0
|
|
|
|
let top = CGPoint(x: cx, y: cy + height / 1.5)
|
|
let bottomLeft = CGPoint(x: cx - triangleSize / 2, y: cy - height / 3)
|
|
let bottomRight = CGPoint(x: cx + triangleSize / 2, y: cy - height / 3)
|
|
|
|
let angle = 35.0 * .pi / 180.0
|
|
func rotate(_ point: CGPoint) -> CGPoint {
|
|
let dx = point.x - cx, dy = point.y - cy
|
|
let cosA = CGFloat(cos(angle)), sinA = CGFloat(sin(angle))
|
|
return CGPoint(x: cx + cosA * dx - sinA * dy, y: cy + sinA * dx + cosA * dy)
|
|
}
|
|
|
|
let path = NSBezierPath()
|
|
path.move(to: rotate(top))
|
|
path.line(to: rotate(bottomLeft))
|
|
path.line(to: rotate(bottomRight))
|
|
path.close()
|
|
|
|
NSColor.black.setFill()
|
|
path.fill()
|
|
|
|
image.unlockFocus()
|
|
return image
|
|
}
|
|
|
|
/// Opens the panel automatically on app launch so the user sees
|
|
/// permissions and the start button right away.
|
|
func showPanelOnLaunch() {
|
|
// Small delay so the status item has time to appear in the menu bar
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
self.showPanel()
|
|
}
|
|
}
|
|
|
|
@objc private func statusItemClicked() {
|
|
if let panel, panel.isVisible {
|
|
hidePanel()
|
|
} else {
|
|
showPanel()
|
|
}
|
|
}
|
|
|
|
// MARK: - Panel Lifecycle
|
|
|
|
private func showPanel() {
|
|
if panel == nil {
|
|
createPanel()
|
|
}
|
|
|
|
positionPanelBelowStatusItem()
|
|
|
|
panel?.makeKeyAndOrderFront(nil)
|
|
panel?.orderFrontRegardless()
|
|
installClickOutsideMonitor()
|
|
}
|
|
|
|
private func hidePanel() {
|
|
panel?.orderOut(nil)
|
|
removeClickOutsideMonitor()
|
|
}
|
|
|
|
private func createPanel() {
|
|
let companionPanelView = CompanionPanelView(companionManager: companionManager)
|
|
.frame(width: panelWidth)
|
|
|
|
let hostingView = NSHostingView(rootView: companionPanelView)
|
|
hostingView.frame = NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight)
|
|
hostingView.wantsLayer = true
|
|
hostingView.layer?.backgroundColor = .clear
|
|
|
|
let menuBarPanel = KeyablePanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
menuBarPanel.isFloatingPanel = true
|
|
menuBarPanel.level = .floating
|
|
menuBarPanel.isOpaque = false
|
|
menuBarPanel.backgroundColor = .clear
|
|
menuBarPanel.hasShadow = false
|
|
menuBarPanel.hidesOnDeactivate = false
|
|
menuBarPanel.isExcludedFromWindowsMenu = true
|
|
menuBarPanel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
menuBarPanel.isMovableByWindowBackground = false
|
|
menuBarPanel.titleVisibility = .hidden
|
|
menuBarPanel.titlebarAppearsTransparent = true
|
|
|
|
menuBarPanel.contentView = hostingView
|
|
panel = menuBarPanel
|
|
}
|
|
|
|
private func positionPanelBelowStatusItem() {
|
|
guard let panel else { return }
|
|
guard let buttonWindow = statusItem?.button?.window else { return }
|
|
|
|
let statusItemFrame = buttonWindow.frame
|
|
let gapBelowMenuBar: CGFloat = 4
|
|
|
|
// Calculate the panel's content height from the hosting view's fitting size
|
|
// so the panel snugly wraps the SwiftUI content instead of using a fixed height.
|
|
let fittingSize = panel.contentView?.fittingSize ?? CGSize(width: panelWidth, height: panelHeight)
|
|
let actualPanelHeight = fittingSize.height
|
|
|
|
// Horizontally center the panel beneath the status item icon
|
|
let panelOriginX = statusItemFrame.midX - (panelWidth / 2)
|
|
let panelOriginY = statusItemFrame.minY - actualPanelHeight - gapBelowMenuBar
|
|
|
|
panel.setFrame(
|
|
NSRect(x: panelOriginX, y: panelOriginY, width: panelWidth, height: actualPanelHeight),
|
|
display: true
|
|
)
|
|
}
|
|
|
|
// MARK: - Click Outside Dismissal
|
|
|
|
/// Installs a global event monitor that hides the panel when the user clicks
|
|
/// anywhere outside it — the same transient dismissal behavior as NSPopover.
|
|
/// Uses a short delay so that system permission dialogs (triggered by Grant
|
|
/// buttons in the panel) don't immediately dismiss the panel when they appear.
|
|
private func installClickOutsideMonitor() {
|
|
removeClickOutsideMonitor()
|
|
|
|
clickOutsideMonitor = NSEvent.addGlobalMonitorForEvents(
|
|
matching: [.leftMouseDown, .rightMouseDown]
|
|
) { [weak self] event in
|
|
guard let self, let panel = self.panel else { return }
|
|
|
|
// Check if the click is inside the status item button — if so, the
|
|
// statusItemClicked handler will toggle the panel, so don't also hide.
|
|
let clickLocation = NSEvent.mouseLocation
|
|
if panel.frame.contains(clickLocation) {
|
|
return
|
|
}
|
|
|
|
// Delay dismissal slightly to avoid closing the panel when
|
|
// a system permission dialog appears (e.g. microphone access).
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
guard panel.isVisible else { return }
|
|
|
|
// If permissions aren't all granted yet, a system dialog
|
|
// may have focus — don't dismiss during onboarding.
|
|
if !self.companionManager.allPermissionsGranted && !NSApp.isActive {
|
|
return
|
|
}
|
|
|
|
self.hidePanel()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeClickOutsideMonitor() {
|
|
if let monitor = clickOutsideMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
clickOutsideMonitor = nil
|
|
}
|
|
}
|
|
}
|