From 5a3df470e823685e488eee9fe44b66d604e7b26e Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 17 Jan 2026 14:45:20 -0500 Subject: [PATCH] feat: big arrow boi --- Gaze/AppDelegate.swift | 19 ++- Gaze/GazeApp.swift | 41 +---- Gaze/Services/MenuBarItemLocator.swift | 154 ++++++++++++++++++ .../Containers/MenuBarGuideOverlayView.swift | 144 ++++++++++++++++ .../Containers/OnboardingContainerView.swift | 84 ++++++---- .../Views/Containers/SettingsWindowView.swift | 13 +- Gaze/Views/Setup/MenuBarWelcomeView.swift | 53 ++++++ GazeTests/OnboardingNavigationTests.swift | 15 +- 8 files changed, 441 insertions(+), 82 deletions(-) create mode 100644 Gaze/Services/MenuBarItemLocator.swift create mode 100644 Gaze/Views/Containers/MenuBarGuideOverlayView.swift create mode 100644 Gaze/Views/Setup/MenuBarWelcomeView.swift diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index bcdfa09..9ab3c30 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -18,7 +18,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private var cancellables = Set() private var hasStartedTimers = false - // Convenience accessor for settings private var settingsManager: any SettingsProviding { serviceContainer.settingsManager } @@ -39,6 +38,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { func applicationDidFinishLaunching(_ notification: Notification) { NSApplication.shared.setActivationPolicy(.accessory) + // Handle test launch arguments + if TestingEnvironment.shouldSkipOnboarding { + SettingsManager.shared.settings.hasCompletedOnboarding = true + } else if TestingEnvironment.shouldResetOnboarding { + SettingsManager.shared.settings.hasCompletedOnboarding = false + } + timerEngine = serviceContainer.timerEngine serviceContainer.setupSmartModeServices() @@ -54,6 +60,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { if settingsManager.settings.hasCompletedOnboarding { startTimers() + } else { + showOnboardingOnLaunch() + } + } + + private func showOnboardingOnLaunch() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.windowManager.showOnboarding(settingsManager: self.settingsManager) } } @@ -97,7 +112,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { settingsManager.settingsPublisher .sink { [weak self] settings in if settings.hasCompletedOnboarding && self?.hasStartedTimers == false { - self?.startTimers() + self?.onboardingCompleted() } else if self?.hasStartedTimers == true { // Defer timer restart to next runloop to ensure settings are fully propagated DispatchQueue.main.async { diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index 1fadd1d..cec3b9a 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -12,41 +12,7 @@ struct GazeApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @State private var settingsManager = SettingsManager.shared - init() { - // Handle test launch arguments - if TestingEnvironment.shouldSkipOnboarding { - SettingsManager.shared.settings.hasCompletedOnboarding = true - } else if TestingEnvironment.shouldResetOnboarding { - SettingsManager.shared.settings.hasCompletedOnboarding = false - } - } - var body: some Scene { - // Onboarding window (only shown when not completed) - WindowGroup { - if settingsManager.settings.hasCompletedOnboarding { - EmptyView() - .onAppear { - closeAllWindows() - } - } else { - OnboardingContainerView(settingsManager: settingsManager) - .onChange(of: settingsManager.settings.hasCompletedOnboarding) { _, completed in - if completed { - closeAllWindows() - appDelegate.onboardingCompleted() - } - } - } - } - .windowStyle(.hiddenTitleBar) - .windowResizability(.contentSize) - .defaultSize(width: 1000, height: 700) - .commands { - CommandGroup(replacing: .newItem) {} - } - - // Menu bar extra (always present) MenuBarExtra("Gaze", systemImage: "eye.fill") { MenuBarContentWrapper( appDelegate: appDelegate, @@ -58,11 +24,8 @@ struct GazeApp: App { ) } .menuBarExtraStyle(.window) - } - - private func closeAllWindows() { - for window in NSApplication.shared.windows { - window.close() + .commands { + CommandGroup(replacing: .newItem) {} } } } diff --git a/Gaze/Services/MenuBarItemLocator.swift b/Gaze/Services/MenuBarItemLocator.swift new file mode 100644 index 0000000..c54ef5e --- /dev/null +++ b/Gaze/Services/MenuBarItemLocator.swift @@ -0,0 +1,154 @@ +// +// MenuBarItemLocator.swift +// Gaze +// +// Created by Mike Freno on 1/17/26. +// + +import AppKit +import Foundation + +struct MenuBarLocationResult { + let frame: CGRect +} + +@MainActor +final class MenuBarItemLocator { + static let shared = MenuBarItemLocator() + + private var cachedLocation: MenuBarLocationResult? + + private init() {} + + func probeLocation() { + // Strategy 1: NSApp.windows at status bar level (most reliable) + if let result = findViaAppWindows() { + print("✅ Strategy 1 (NSApp.windows level 25): \(result.frame)") + cachedLocation = result + return + } + + // Strategy 2: CGWindowList + if let result = findViaCGWindowList() { + print("✅ Strategy 2 (CGWindowList): \(result.frame)") + cachedLocation = result + return + } + + // Strategy 3: Calculate based on screen geometry + if let result = calculateFromScreenGeometry() { + print("✅ Strategy 3 (Screen geometry fallback): \(result.frame)") + cachedLocation = result + return + } + + print("❌ All strategies failed") + } + + func getLocation() -> MenuBarLocationResult? { + if cachedLocation == nil { + probeLocation() + } + return cachedLocation + } + + /// Strategy 1: Find windows at status bar level (25) in NSApp.windows + private func findViaAppWindows() -> MenuBarLocationResult? { + guard let screen = NSScreen.main else { return nil } + + let menuBarHeight = NSStatusBar.system.thickness + let screenFrame = screen.frame + + for window in NSApp.windows { + let frame = window.frame + let level = window.level.rawValue + + // Status bar level is 25 + let isStatusBarLevel = level == 25 + + // Status item windows have small dimensions + let hasSmallHeight = frame.height > 0 && frame.height <= 50 + let hasSmallWidth = frame.width > 0 && frame.width < 100 + + if isStatusBarLevel && hasSmallHeight && hasSmallWidth { + // We found it! Use the x position, but set y to top of screen + let targetFrame = CGRect( + x: frame.minX, + y: screenFrame.maxY - menuBarHeight, + width: frame.width, + height: menuBarHeight + ) + return MenuBarLocationResult(frame: targetFrame) + } + } + + return nil + } + + /// Strategy 2: Use CGWindowListCopyWindowInfo + private func findViaCGWindowList() -> MenuBarLocationResult? { + let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements) + guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return nil + } + + let myPID = ProcessInfo.processInfo.processIdentifier + guard let screen = NSScreen.main else { return nil } + + let menuBarHeight = NSStatusBar.system.thickness + let screenHeight = screen.frame.height + + for info in windowList { + guard let ownerPID = info[kCGWindowOwnerPID as String] as? Int32, + ownerPID == myPID, + let boundsDict = info[kCGWindowBounds as String] as? [String: CGFloat], + let x = boundsDict["X"], + let y = boundsDict["Y"], + let width = boundsDict["Width"], + let height = boundsDict["Height"] + else { continue } + + // CGWindowList uses top-left origin (y=0 at top) + let isAtTop = y < menuBarHeight + 10 + let hasSmallHeight = height > 0 && height <= 50 + let hasSmallWidth = width > 0 && width < 100 + + if isAtTop && hasSmallHeight && hasSmallWidth { + let frame = CGRect( + x: x, + y: screenHeight - menuBarHeight, + width: width, + height: menuBarHeight + ) + return MenuBarLocationResult(frame: frame) + } + } + + return nil + } + + /// Strategy 3: Fallback calculation based on screen geometry + private func calculateFromScreenGeometry() -> MenuBarLocationResult? { + guard let screen = NSScreen.main else { return nil } + + let menuBarHeight = NSStatusBar.system.thickness + let screenFrame = screen.frame + + // Estimate: status items typically around 2/3 from left + let estimatedX = screenFrame.width * 0.667 + + let frame = CGRect( + x: estimatedX, + y: screenFrame.maxY - menuBarHeight, + width: 24, + height: menuBarHeight + ) + + return MenuBarLocationResult(frame: frame) + } + + func refreshLocation() { + cachedLocation = nil + probeLocation() + } +} diff --git a/Gaze/Views/Containers/MenuBarGuideOverlayView.swift b/Gaze/Views/Containers/MenuBarGuideOverlayView.swift new file mode 100644 index 0000000..6933f12 --- /dev/null +++ b/Gaze/Views/Containers/MenuBarGuideOverlayView.swift @@ -0,0 +1,144 @@ +// +// MenuBarGuideOverlayView.swift +// Gaze +// +// Created by Mike Freno on 1/17/26. +// + +import AppKit +import SwiftUI + +@MainActor +final class MenuBarGuideOverlayPresenter { + static let shared = MenuBarGuideOverlayPresenter() + + private var window: NSWindow? + + func updateVisibility(isVisible: Bool) { + if isVisible { + // Probe location before showing + MenuBarItemLocator.shared.probeLocation() + show() + } else { + hide() + } + } + + func hide() { + window?.orderOut(nil) + window?.close() + window = nil + } + + private func show() { + if let window { + window.orderFrontRegardless() + return + } + + guard let screen = NSScreen.main else { return } + + let overlayWindow = NSPanel( + contentRect: screen.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + overlayWindow.backgroundColor = .clear + overlayWindow.isOpaque = false + overlayWindow.hasShadow = false + overlayWindow.level = .statusBar + overlayWindow.ignoresMouseEvents = true + overlayWindow.isReleasedWhenClosed = false + overlayWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + overlayWindow.contentView = NSHostingView(rootView: MenuBarGuideOverlayView()) + + overlayWindow.orderFrontRegardless() + window = overlayWindow + } +} + +struct MenuBarGuideOverlayView: View { + var body: some View { + GeometryReader { proxy in + let size = proxy.size + + if let locationResult = MenuBarItemLocator.shared.getLocation(), + let screen = NSScreen.main + { + let target = convertToViewCoordinates( + frame: locationResult.frame, + screenHeight: screen.frame.height + ) + + // 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) + .stroke( + Color.accentColor.opacity(0.9), + style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round) + ) + .shadow(color: .black.opacity(0.3), radius: 8, 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) + } +} + +struct CurvedArrowShape: 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) + + // Arrowhead + let arrowLength: CGFloat = 18 + let arrowAngle: CGFloat = .pi / 7 + let angle = atan2(end.y - control.y, end.x - control.x) + + let left = CGPoint( + x: end.x - arrowLength * cos(angle - arrowAngle), + y: end.y - arrowLength * sin(angle - arrowAngle) + ) + let right = CGPoint( + x: end.x - arrowLength * cos(angle + arrowAngle), + y: end.y - arrowLength * sin(angle + arrowAngle) + ) + + path.move(to: end) + path.addLine(to: left) + path.move(to: end) + path.addLine(to: right) + + return path + } +} + +#Preview("Menu Bar Guide Overlay") { + MenuBarGuideOverlayView() + .frame(width: 1200, height: 800) +} diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index 1a4747d..489fc52 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -5,7 +5,6 @@ // Created by Mike Freno on 1/7/26. // -import AppKit import SwiftUI struct VisualEffectView: NSViewRepresentable { @@ -30,7 +29,7 @@ struct VisualEffectView: NSViewRepresentable { final class OnboardingWindowPresenter { static let shared = OnboardingWindowPresenter() - private weak var windowController: NSWindowController? + private var windowController: NSWindowController? private var closeObserver: NSObjectProtocol? private var isShowingWindow = false @@ -43,36 +42,41 @@ final class OnboardingWindowPresenter { @discardableResult func activateIfPresent() -> Bool { - guard let window = windowController?.window else { - windowController = nil + guard let window = windowController?.window, window.isVisible else { return false } - DispatchQueue.main.async { - NSApp.unhide(nil) - NSApp.activate(ignoringOtherApps: true) + NSApp.unhide(nil) + NSApp.activate(ignoringOtherApps: true) - if window.isMiniaturized { - window.deminiaturize(nil) - } - - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - window.makeMain() + if window.isMiniaturized { + window.deminiaturize(nil) } + + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + window.makeMain() return true } func close() { - windowController?.close() + removeCloseObserver() + windowController?.window?.close() windowController = nil isShowingWindow = false - removeCloseObserver() } private func createWindow(settingsManager: SettingsManager) { let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), + contentRect: NSRect( + x: 0, y: 0, width: 1000, + height: { + #if APPSTORE + return 700 + #else + return 1000 + #endif + }()), styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], backing: .buffered, defer: false @@ -82,7 +86,7 @@ final class OnboardingWindowPresenter { window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.center() - window.isReleasedWhenClosed = true + window.isReleasedWhenClosed = false window.collectionBehavior = [ .managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary, ] @@ -128,6 +132,8 @@ struct OnboardingContainerView: View { @Bindable var settingsManager: SettingsManager @State private var currentPage = 0 + private let lastPageIndex = 6 + var body: some View { ZStack { VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) @@ -138,36 +144,53 @@ struct OnboardingContainerView: View { .tag(0) .tabItem { Image(systemName: "hand.wave.fill") } - LookAwaySetupView(settingsManager: settingsManager) + MenuBarWelcomeView() .tag(1) + .tabItem { Image(systemName: "menubar.rectangle") } + + LookAwaySetupView(settingsManager: settingsManager) + .tag(2) .tabItem { Image(systemName: "eye.fill") } BlinkSetupView(settingsManager: settingsManager) - .tag(2) + .tag(3) .tabItem { Image(systemName: "eye.circle.fill") } PostureSetupView(settingsManager: settingsManager) - .tag(3) + .tag(4) .tabItem { Image(systemName: "figure.stand") } GeneralSetupView(settingsManager: settingsManager, isOnboarding: true) - .tag(4) + .tag(5) .tabItem { Image(systemName: "gearshape.fill") } CompletionView() - .tag(5) + .tag(6) .tabItem { Image(systemName: "checkmark.circle.fill") } } .tabViewStyle(.automatic) + .onChange(of: currentPage) { _, newValue in + MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: newValue == 1) + } navigationButtons } } - #if APPSTORE - .frame(minWidth: 1000, minHeight: 700) - #else - .frame(minWidth: 1000, minHeight: 900) - #endif + .frame( + minWidth: 1000, + minHeight: { + #if APPSTORE + return 700 + #else + return 1000 + #endif + }()) + .onAppear { + MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1) + } + .onDisappear { + MenuBarGuideOverlayPresenter.shared.hide() + } } @ViewBuilder @@ -190,7 +213,7 @@ struct OnboardingContainerView: View { } Button(action: { - if currentPage == 5 { + if currentPage == lastPageIndex { completeOnboarding() } else { currentPage += 1 @@ -198,7 +221,8 @@ struct OnboardingContainerView: View { }) { Text( currentPage == 0 - ? "Let's Get Started" : currentPage == 5 ? "Get Started" : "Continue" + ? "Let's Get Started" + : currentPage == lastPageIndex ? "Get Started" : "Continue" ) .font(.headline) .frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44) diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index a36a964..649ef31 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -11,7 +11,7 @@ import SwiftUI final class SettingsWindowPresenter { static let shared = SettingsWindowPresenter() - private weak var windowController: NSWindowController? + private var windowController: NSWindowController? private var closeObserver: NSObjectProtocol? private var isShowingWindow = false @@ -78,8 +78,10 @@ final class SettingsWindowPresenter { window.setFrameAutosaveName("SettingsWindow") window.isReleasedWhenClosed = false - window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary] - + window.collectionBehavior = [ + .managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary, + ] + window.contentView = NSHostingView( rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab) ) @@ -204,8 +206,9 @@ struct SettingsWindowView: View { #if DEBUG private func retriggerOnboarding() { SettingsWindowPresenter.shared.close() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - settingsManager.settings.hasCompletedOnboarding = false + settingsManager.settings.hasCompletedOnboarding = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + OnboardingWindowPresenter.shared.show(settingsManager: settingsManager) } } #endif diff --git a/Gaze/Views/Setup/MenuBarWelcomeView.swift b/Gaze/Views/Setup/MenuBarWelcomeView.swift new file mode 100644 index 0000000..a507db1 --- /dev/null +++ b/Gaze/Views/Setup/MenuBarWelcomeView.swift @@ -0,0 +1,53 @@ +// +// MenuBarWelcomeView.swift +// Gaze +// +// Created by Mike Freno on 1/17/26. +// + +import SwiftUI + +struct MenuBarWelcomeView: View { + var body: some View { + VStack(spacing: 30) { + Spacer() + + Image(systemName: "menubar.rectangle") + .font(.system(size: 72)) + .foregroundStyle(Color.accentColor) + + VStack(spacing: 8) { + Text("Gaze Lives in Your Menu Bar") + .font(.system(size: 34, weight: .bold)) + + Text("Keep an eye on the top-right of your screen for the Gaze icon.") + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(alignment: .leading, spacing: 16) { + FeatureRow( + icon: "cursorarrow.click", title: "Always Within Reach", + description: "Open settings and timers from the menu bar anytime") + FeatureRow( + icon: "bell.badge", title: "Friendly Reminders", + description: "Notifications pop up without interrupting your flow") + FeatureRow( + icon: "sparkles", title: "Quick Tweaks", + description: "Pause, resume, and adjust timers in one click") + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + + Spacer() + } + .frame(width: 600, height: 450) + .padding() + .background(.clear) + } +} + +#Preview("Menu Bar Welcome") { + MenuBarWelcomeView() +} diff --git a/GazeTests/OnboardingNavigationTests.swift b/GazeTests/OnboardingNavigationTests.swift index e0ac372..e4eedf0 100644 --- a/GazeTests/OnboardingNavigationTests.swift +++ b/GazeTests/OnboardingNavigationTests.swift @@ -41,11 +41,12 @@ final class OnboardingNavigationTests: XCTestCase { // Simulate moving through pages let pages = [ "Welcome", // 0 - "LookAway", // 1 - "Blink", // 2 - "Posture", // 3 - "General", // 4 - "Completion", // 5 + "MenuBar", // 1 + "LookAway", // 2 + "Blink", // 3 + "Posture", // 4 + "General", // 5 + "Completion", // 6 ] for (index, pageName) in pages.enumerated() { @@ -177,7 +178,9 @@ final class OnboardingNavigationTests: XCTestCase { // Page 0: Welcome - no configuration needed - // Page 1: LookAway Setup + // Page 1: MenuBar Welcome - no configuration needed + + // Page 2: LookAway Setup var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer lookAwayConfig.enabled = true lookAwayConfig.intervalSeconds = 1200