// // CompanionResponseOverlay.swift // leanring-buddy // // Cursor-following overlay that displays streaming AI response text. // Uses a non-activating NSPanel so it floats above all apps without // stealing focus, and repositions itself near the mouse cursor each frame. // import AppKit import Combine import SwiftUI // MARK: - View Model @MainActor final class CompanionResponseOverlayViewModel: ObservableObject { @Published var streamingResponseText: String = "" @Published var isShowingResponse: Bool = false } // MARK: - Overlay Manager @MainActor final class CompanionResponseOverlayManager { private let overlayViewModel = CompanionResponseOverlayViewModel() private var overlayPanel: NSPanel? private var cursorTrackingTimer: Timer? private var autoHideWorkItem: DispatchWorkItem? /// The horizontal offset from the cursor to the left edge of the overlay panel. private let cursorOffsetX: CGFloat = 22 /// The vertical offset from the cursor downward to the top edge of the overlay panel. private let cursorOffsetY: CGFloat = 6 /// Maximum width of the overlay panel. private let overlayMaxWidth: CGFloat = 340 func showOverlayAndBeginStreaming() { autoHideWorkItem?.cancel() autoHideWorkItem = nil overlayViewModel.streamingResponseText = "" overlayViewModel.isShowingResponse = true createOverlayPanelIfNeeded() startCursorTracking() overlayPanel?.alphaValue = 1 overlayPanel?.orderFrontRegardless() } func updateStreamingText(_ accumulatedText: String) { overlayViewModel.streamingResponseText = accumulatedText resizePanelToFitContent() } func finishStreaming() { // Keep the response visible for a few seconds after streaming ends, // then fade out so the user has time to read the last chunk. let hideWork = DispatchWorkItem { [weak self] in self?.fadeOutAndHide() } autoHideWorkItem = hideWork DispatchQueue.main.asyncAfter(deadline: .now() + 6, execute: hideWork) } func hideOverlay() { autoHideWorkItem?.cancel() autoHideWorkItem = nil stopCursorTracking() overlayViewModel.isShowingResponse = false overlayViewModel.streamingResponseText = "" overlayPanel?.orderOut(nil) } // MARK: - Private private func createOverlayPanelIfNeeded() { if overlayPanel != nil { return } let initialFrame = NSRect(x: 0, y: 0, width: overlayMaxWidth, height: 40) let responseOverlayPanel = NSPanel( contentRect: initialFrame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) responseOverlayPanel.level = .statusBar responseOverlayPanel.isOpaque = false responseOverlayPanel.backgroundColor = .clear responseOverlayPanel.hasShadow = false responseOverlayPanel.ignoresMouseEvents = true responseOverlayPanel.hidesOnDeactivate = false responseOverlayPanel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] responseOverlayPanel.isExcludedFromWindowsMenu = true let hostingView = NSHostingView( rootView: CompanionResponseOverlayView(viewModel: overlayViewModel) .frame(maxWidth: overlayMaxWidth) ) hostingView.frame = initialFrame responseOverlayPanel.contentView = hostingView overlayPanel = responseOverlayPanel } private func startCursorTracking() { // 60fps cursor tracking so the panel stays glued to the mouse cursorTrackingTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.repositionPanelNearCursor() } } } private func stopCursorTracking() { cursorTrackingTimer?.invalidate() cursorTrackingTimer = nil } private func repositionPanelNearCursor() { guard let overlayPanel else { return } let mouseLocation = NSEvent.mouseLocation let panelSize = overlayPanel.frame.size // Position the panel to the right of and slightly below the cursor. // In macOS screen coordinates, Y increases upward, so "below" means // subtracting from the cursor Y. var panelOriginX = mouseLocation.x + cursorOffsetX var panelOriginY = mouseLocation.y - cursorOffsetY - panelSize.height // Clamp to the visible frame of the screen containing the cursor // so the panel never goes off-screen. if let currentScreen = screenContainingPoint(mouseLocation) { let visibleFrame = currentScreen.visibleFrame // If the panel would go off the right edge, flip it to the left of the cursor if panelOriginX + panelSize.width > visibleFrame.maxX { panelOriginX = mouseLocation.x - cursorOffsetX - panelSize.width } // If the panel would go below the bottom edge, push it above the cursor if panelOriginY < visibleFrame.minY { panelOriginY = mouseLocation.y + cursorOffsetY } // Final clamp panelOriginX = max(visibleFrame.minX, min(panelOriginX, visibleFrame.maxX - panelSize.width)) panelOriginY = max(visibleFrame.minY, min(panelOriginY, visibleFrame.maxY - panelSize.height)) } overlayPanel.setFrameOrigin(CGPoint(x: panelOriginX, y: panelOriginY)) } private func resizePanelToFitContent() { guard let overlayPanel, let contentView = overlayPanel.contentView else { return } let fittingSize = contentView.fittingSize let newWidth = min(fittingSize.width, overlayMaxWidth) let newHeight = fittingSize.height // Keep the panel origin relative to the cursor (the timer handles that), // but update the frame size so the content fits. var frame = overlayPanel.frame let heightDelta = newHeight - frame.height frame.size = CGSize(width: newWidth, height: newHeight) // Adjust origin Y so the panel grows upward (toward the cursor), not downward frame.origin.y -= heightDelta overlayPanel.setFrame(frame, display: true) contentView.frame = NSRect(origin: .zero, size: frame.size) } private func fadeOutAndHide() { guard let overlayPanel else { return } NSAnimationContext.runAnimationGroup({ context in context.duration = 0.4 overlayPanel.animator().alphaValue = 0 }, completionHandler: { [weak self] in Task { @MainActor in self?.hideOverlay() } }) } private func screenContainingPoint(_ point: CGPoint) -> NSScreen? { NSScreen.screens.first { $0.frame.contains(point) } } } // MARK: - SwiftUI View private struct CompanionResponseOverlayView: View { @ObservedObject var viewModel: CompanionResponseOverlayViewModel var body: some View { if viewModel.isShowingResponse { Text(viewModel.streamingResponseText.isEmpty ? "..." : viewModel.streamingResponseText) .font(.system(size: 13, weight: .regular)) .foregroundColor(DS.Colors.textPrimary) .lineSpacing(3) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 300, alignment: .leading) .padding(.horizontal, 14) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(DS.Colors.surface1.opacity(0.95)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(DS.Colors.borderSubtle.opacity(0.5), lineWidth: 0.8) ) .shadow(color: Color.black.opacity(0.35), radius: 16, x: 0, y: 8) ) } } }