feat: nice!

This commit is contained in:
Michael Freno
2026-01-17 20:30:12 -05:00
parent 5a3df470e8
commit 287dca61a8
2 changed files with 132 additions and 41 deletions

View File

@@ -6,6 +6,7 @@
// //
import AppKit import AppKit
import Combine
import SwiftUI import SwiftUI
@MainActor @MainActor
@@ -13,10 +14,11 @@ final class MenuBarGuideOverlayPresenter {
static let shared = MenuBarGuideOverlayPresenter() static let shared = MenuBarGuideOverlayPresenter()
private var window: NSWindow? private var window: NSWindow?
private var displayLink: CVDisplayLink?
private var lastWindowFrame: CGRect = .zero
func updateVisibility(isVisible: Bool) { func updateVisibility(isVisible: Bool) {
if isVisible { if isVisible {
// Probe location before showing
MenuBarItemLocator.shared.probeLocation() MenuBarItemLocator.shared.probeLocation()
show() show()
} else { } else {
@@ -25,6 +27,7 @@ final class MenuBarGuideOverlayPresenter {
} }
func hide() { func hide() {
stopDisplayLink()
window?.orderOut(nil) window?.orderOut(nil)
window?.close() window?.close()
window = nil window = nil
@@ -33,6 +36,7 @@ final class MenuBarGuideOverlayPresenter {
private func show() { private func show() {
if let window { if let window {
window.orderFrontRegardless() window.orderFrontRegardless()
startDisplayLink()
return return
} }
@@ -52,10 +56,66 @@ final class MenuBarGuideOverlayPresenter {
overlayWindow.ignoresMouseEvents = true overlayWindow.ignoresMouseEvents = true
overlayWindow.isReleasedWhenClosed = false overlayWindow.isReleasedWhenClosed = false
overlayWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] overlayWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
overlayWindow.contentView = NSHostingView(rootView: MenuBarGuideOverlayView())
let overlayView = MenuBarGuideOverlayView()
overlayWindow.contentView = NSHostingView(rootView: overlayView)
overlayWindow.orderFrontRegardless() overlayWindow.orderFrontRegardless()
window = overlayWindow 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<MenuBarGuideOverlayPresenter>.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(), if let locationResult = MenuBarItemLocator.shared.getLocation(),
let screen = NSScreen.main let screen = NSScreen.main
{ {
let target = convertToViewCoordinates( let target = targetPoint(from: locationResult.frame)
frame: locationResult.frame, let start = startPoint(screenSize: size, screenFrame: screen.frame)
screenHeight: screen.frame.height
)
// Adjust control and start points based on target position HandDrawnArrowShape(start: start, end: target)
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)
.stroke( .stroke(
Color.accentColor.opacity(0.9), Color.accentColor.opacity(0.85),
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round) 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) .allowsHitTesting(false)
.background(Color.clear) .background(Color.clear)
} }
private func convertToViewCoordinates(frame: CGRect, screenHeight: CGFloat) -> CGPoint { private func targetPoint(from frame: CGRect) -> CGPoint {
// We want to point to the center of the menu bar icon CGPoint(x: frame.midX, y: 30)
// 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 private func startPoint(screenSize: CGSize, screenFrame: CGRect) -> CGPoint {
let targetY: CGFloat = 30 // Fixed y near top of screen // Calculate start point based on onboarding window position
return CGPoint(x: centerX, y: targetY) // 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 start: CGPoint
let end: CGPoint let end: CGPoint
let control: CGPoint
func path(in rect: CGRect) -> Path { func path(in rect: CGRect) -> Path {
var path = Path() var path = Path()
path.move(to: start) // Create a path that starts going DOWN, then curves back UP to the target
path.addQuadCurve(to: end, control: control) // This creates a more playful, hand-drawn feel
// Arrowhead let dx = end.x - start.x
let arrowLength: CGFloat = 18 let dy = end.y - start.y
let arrowAngle: CGFloat = .pi / 7
let angle = atan2(end.y - control.y, end.x - control.x) // 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( let left = CGPoint(
x: end.x - arrowLength * cos(angle - arrowAngle), x: end.x - arrowLength * cos(angle - arrowAngle),
@@ -129,10 +217,11 @@ struct CurvedArrowShape: Shape {
y: end.y - arrowLength * sin(angle + arrowAngle) y: end.y - arrowLength * sin(angle + arrowAngle)
) )
path.move(to: end) // Draw arrowhead with slight wobble
path.addLine(to: left) path.move(to: CGPoint(x: end.x + 1, y: end.y - 1))
path.move(to: end) path.addLine(to: CGPoint(x: left.x - 1, y: left.y + 1))
path.addLine(to: right) 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 return path
} }

View File

@@ -184,7 +184,8 @@ struct OnboardingContainerView: View {
#else #else
return 1000 return 1000
#endif #endif
}()) }()
)
.onAppear { .onAppear {
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1) MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1)
} }
@@ -231,7 +232,8 @@ struct OnboardingContainerView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffectIfAvailable( .glassEffectIfAvailable(
GlassStyle.regular.tint(currentPage == 5 ? .green : .accentColor).interactive(), GlassStyle.regular.tint(currentPage == lastPageIndex ? .green : .accentColor)
.interactive(),
in: .rect(cornerRadius: 10) in: .rect(cornerRadius: 10)
) )
} }