feat: nice!
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user