diff --git a/Gaze/Views/Containers/MenuBarGuideOverlayView.swift b/Gaze/Views/Containers/MenuBarGuideOverlayView.swift index 6933f12..7bd628e 100644 --- a/Gaze/Views/Containers/MenuBarGuideOverlayView.swift +++ b/Gaze/Views/Containers/MenuBarGuideOverlayView.swift @@ -6,6 +6,7 @@ // import AppKit +import Combine import SwiftUI @MainActor @@ -13,10 +14,11 @@ final class MenuBarGuideOverlayPresenter { static let shared = MenuBarGuideOverlayPresenter() private var window: NSWindow? + private var displayLink: CVDisplayLink? + private var lastWindowFrame: CGRect = .zero func updateVisibility(isVisible: Bool) { if isVisible { - // Probe location before showing MenuBarItemLocator.shared.probeLocation() show() } else { @@ -25,6 +27,7 @@ final class MenuBarGuideOverlayPresenter { } func hide() { + stopDisplayLink() window?.orderOut(nil) window?.close() window = nil @@ -33,6 +36,7 @@ final class MenuBarGuideOverlayPresenter { private func show() { if let window { window.orderFrontRegardless() + startDisplayLink() return } @@ -52,10 +56,66 @@ final class MenuBarGuideOverlayPresenter { overlayWindow.ignoresMouseEvents = true overlayWindow.isReleasedWhenClosed = false overlayWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] - overlayWindow.contentView = NSHostingView(rootView: MenuBarGuideOverlayView()) + + let overlayView = MenuBarGuideOverlayView() + overlayWindow.contentView = NSHostingView(rootView: overlayView) overlayWindow.orderFrontRegardless() window = overlayWindow + + startDisplayLink() + } + + private func startDisplayLink() { + guard displayLink == nil else { return } + + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + + guard let displayLink = link else { return } + + let Doc = Unmanaged.passUnretained(self).toOpaque() + + CVDisplayLinkSetOutputCallback( + displayLink, + { _, _, _, _, _, userInfo -> CVReturn in + guard let userInfo = userInfo else { return kCVReturnSuccess } + let presenter = Unmanaged.fromOpaque(userInfo) + .takeUnretainedValue() + DispatchQueue.main.async { + presenter.checkAndRedraw() + } + return kCVReturnSuccess + }, Doc) + + CVDisplayLinkStart(displayLink) + self.displayLink = displayLink + } + + private func stopDisplayLink() { + guard let displayLink else { return } + CVDisplayLinkStop(displayLink) + self.displayLink = nil + } + + private func checkAndRedraw() { + guard + let onboardingWindow = NSApp.windows.first(where: { + $0.identifier == WindowIdentifiers.onboarding + }) + else { return } + + let currentFrame = onboardingWindow.frame + if currentFrame != lastWindowFrame { + lastWindowFrame = currentFrame + redraw() + } + } + + private func redraw() { + guard let window else { return } + let overlayView = MenuBarGuideOverlayView() + window.contentView = NSHostingView(rootView: overlayView) } } @@ -67,58 +127,86 @@ struct MenuBarGuideOverlayView: View { if let locationResult = MenuBarItemLocator.shared.getLocation(), let screen = NSScreen.main { - let target = convertToViewCoordinates( - frame: locationResult.frame, - screenHeight: screen.frame.height - ) + let target = targetPoint(from: locationResult.frame) + let start = startPoint(screenSize: size, screenFrame: screen.frame) - // Adjust control and start points based on target position - let control = CGPoint( - x: target.x * 0.9 + size.width * 0.1, - y: size.height * 0.15 - ) - let start = CGPoint( - x: size.width * 0.5, - y: size.height * 0.45 - ) - - CurvedArrowShape(start: start, end: target, control: control) + HandDrawnArrowShape(start: start, end: target) .stroke( - Color.accentColor.opacity(0.9), - style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round) + Color.accentColor.opacity(0.85), + style: StrokeStyle(lineWidth: 3.5, lineCap: .round, lineJoin: .round) ) - .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 2) + .shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 2) } } .allowsHitTesting(false) .background(Color.clear) } - private func convertToViewCoordinates(frame: CGRect, screenHeight: CGFloat) -> CGPoint { - // We want to point to the center of the menu bar icon - // x: use the center of the detected frame - // y: fixed at ~20 from top (menu bar is at top of screen in view coordinates) - let centerX = frame.midX - let targetY: CGFloat = 30 // Fixed y near top of screen - return CGPoint(x: centerX, y: targetY) + private func targetPoint(from frame: CGRect) -> CGPoint { + CGPoint(x: frame.midX, y: 30) + } + + private func startPoint(screenSize: CGSize, screenFrame: CGRect) -> CGPoint { + // Calculate start point based on onboarding window position + // Arrow starts from right side of title text area (approximately) + if let onboardingWindow = NSApp.windows.first(where: { + $0.identifier == WindowIdentifiers.onboarding + }) { + let windowFrame = onboardingWindow.frame + let textRightX = windowFrame.midX + 210 + let textY = screenFrame.maxY - windowFrame.maxY + 505 + return CGPoint(x: textRightX, y: textY) + } + return CGPoint(x: screenSize.width * 0.5, y: screenSize.height * 0.45) } } -struct CurvedArrowShape: Shape { +struct HandDrawnArrowShape: Shape { let start: CGPoint let end: CGPoint - let control: CGPoint func path(in rect: CGRect) -> Path { var path = Path() - path.move(to: start) - path.addQuadCurve(to: end, control: control) + // Create a path that starts going DOWN, then curves back UP to the target + // This creates a more playful, hand-drawn feel - // Arrowhead - let arrowLength: CGFloat = 18 - let arrowAngle: CGFloat = .pi / 7 - let angle = atan2(end.y - control.y, end.x - control.x) + let dx = end.x - start.x + let dy = end.y - start.y + + // First control point: go DOWN and slightly toward target + let ctrl1 = CGPoint( + x: start.x + dx * 0.15, + y: start.y + 120 // Go DOWN first + ) + + // Second control point: curve back up toward target + let ctrl2 = CGPoint( + x: start.x + dx * 0.6, + y: start.y + 80 + ) + + // Third control point: approach target from below-ish + let ctrl3 = CGPoint( + x: end.x - dx * 0.15, + y: end.y + 60 + ) + + // Add slight hand-drawn wobble + let wobble: CGFloat = 2.5 + let wobbledCtrl1 = CGPoint(x: ctrl1.x + wobble, y: ctrl1.y - wobble) + let wobbledCtrl2 = CGPoint(x: ctrl2.x - wobble, y: ctrl2.y + wobble) + let wobbledCtrl3 = CGPoint(x: ctrl3.x + wobble * 0.5, y: ctrl3.y - wobble) + + path.move(to: start) + path.addCurve(to: end, control1: wobbledCtrl1, control2: wobbledCtrl2) + + // Arrowhead - angled based on the final curve direction + let arrowLength: CGFloat = 16 + let arrowAngle: CGFloat = .pi / 6 + + // Calculate angle from last control point to end + let angle = atan2(end.y - wobbledCtrl2.y, end.x - wobbledCtrl2.x) let left = CGPoint( x: end.x - arrowLength * cos(angle - arrowAngle), @@ -129,10 +217,11 @@ struct CurvedArrowShape: Shape { y: end.y - arrowLength * sin(angle + arrowAngle) ) - path.move(to: end) - path.addLine(to: left) - path.move(to: end) - path.addLine(to: right) + // Draw arrowhead with slight wobble + path.move(to: CGPoint(x: end.x + 1, y: end.y - 1)) + path.addLine(to: CGPoint(x: left.x - 1, y: left.y + 1)) + path.move(to: CGPoint(x: end.x - 1, y: end.y + 1)) + path.addLine(to: CGPoint(x: right.x + 1, y: right.y - 1)) return path } diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index 489fc52..4d75ad6 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -184,7 +184,8 @@ struct OnboardingContainerView: View { #else return 1000 #endif - }()) + }() + ) .onAppear { MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1) } @@ -231,7 +232,8 @@ struct OnboardingContainerView: View { } .buttonStyle(.plain) .glassEffectIfAvailable( - GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor).interactive(), + GlassStyle.regular.tint(currentPage == lastPageIndex ? .green : .accentColor) + .interactive(), in: .rect(cornerRadius: 10) ) }