feat: big arrow boi

This commit is contained in:
Michael Freno
2026-01-17 14:45:20 -05:00
parent a528a549b9
commit 5a3df470e8
8 changed files with 441 additions and 82 deletions

View File

@@ -18,7 +18,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var hasStartedTimers = false private var hasStartedTimers = false
// Convenience accessor for settings
private var settingsManager: any SettingsProviding { private var settingsManager: any SettingsProviding {
serviceContainer.settingsManager serviceContainer.settingsManager
} }
@@ -39,6 +38,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
NSApplication.shared.setActivationPolicy(.accessory) 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 timerEngine = serviceContainer.timerEngine
serviceContainer.setupSmartModeServices() serviceContainer.setupSmartModeServices()
@@ -54,6 +60,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
if settingsManager.settings.hasCompletedOnboarding { if settingsManager.settings.hasCompletedOnboarding {
startTimers() 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 settingsManager.settingsPublisher
.sink { [weak self] settings in .sink { [weak self] settings in
if settings.hasCompletedOnboarding && self?.hasStartedTimers == false { if settings.hasCompletedOnboarding && self?.hasStartedTimers == false {
self?.startTimers() self?.onboardingCompleted()
} else if self?.hasStartedTimers == true { } else if self?.hasStartedTimers == true {
// Defer timer restart to next runloop to ensure settings are fully propagated // Defer timer restart to next runloop to ensure settings are fully propagated
DispatchQueue.main.async { DispatchQueue.main.async {

View File

@@ -12,41 +12,7 @@ struct GazeApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var settingsManager = SettingsManager.shared @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 { 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") { MenuBarExtra("Gaze", systemImage: "eye.fill") {
MenuBarContentWrapper( MenuBarContentWrapper(
appDelegate: appDelegate, appDelegate: appDelegate,
@@ -58,11 +24,8 @@ struct GazeApp: App {
) )
} }
.menuBarExtraStyle(.window) .menuBarExtraStyle(.window)
} .commands {
CommandGroup(replacing: .newItem) {}
private func closeAllWindows() {
for window in NSApplication.shared.windows {
window.close()
} }
} }
} }

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -5,7 +5,6 @@
// Created by Mike Freno on 1/7/26. // Created by Mike Freno on 1/7/26.
// //
import AppKit
import SwiftUI import SwiftUI
struct VisualEffectView: NSViewRepresentable { struct VisualEffectView: NSViewRepresentable {
@@ -30,7 +29,7 @@ struct VisualEffectView: NSViewRepresentable {
final class OnboardingWindowPresenter { final class OnboardingWindowPresenter {
static let shared = OnboardingWindowPresenter() static let shared = OnboardingWindowPresenter()
private weak var windowController: NSWindowController? private var windowController: NSWindowController?
private var closeObserver: NSObjectProtocol? private var closeObserver: NSObjectProtocol?
private var isShowingWindow = false private var isShowingWindow = false
@@ -43,36 +42,41 @@ final class OnboardingWindowPresenter {
@discardableResult @discardableResult
func activateIfPresent() -> Bool { func activateIfPresent() -> Bool {
guard let window = windowController?.window else { guard let window = windowController?.window, window.isVisible else {
windowController = nil
return false return false
} }
DispatchQueue.main.async { NSApp.unhide(nil)
NSApp.unhide(nil) NSApp.activate(ignoringOtherApps: true)
NSApp.activate(ignoringOtherApps: true)
if window.isMiniaturized { if window.isMiniaturized {
window.deminiaturize(nil) window.deminiaturize(nil)
}
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
window.makeMain()
} }
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
window.makeMain()
return true return true
} }
func close() { func close() {
windowController?.close() removeCloseObserver()
windowController?.window?.close()
windowController = nil windowController = nil
isShowingWindow = false isShowingWindow = false
removeCloseObserver()
} }
private func createWindow(settingsManager: SettingsManager) { private func createWindow(settingsManager: SettingsManager) {
let window = NSWindow( 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], styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
backing: .buffered, backing: .buffered,
defer: false defer: false
@@ -82,7 +86,7 @@ final class OnboardingWindowPresenter {
window.titleVisibility = .hidden window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
window.center() window.center()
window.isReleasedWhenClosed = true window.isReleasedWhenClosed = false
window.collectionBehavior = [ window.collectionBehavior = [
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary, .managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
] ]
@@ -128,6 +132,8 @@ struct OnboardingContainerView: View {
@Bindable var settingsManager: SettingsManager @Bindable var settingsManager: SettingsManager
@State private var currentPage = 0 @State private var currentPage = 0
private let lastPageIndex = 6
var body: some View { var body: some View {
ZStack { ZStack {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
@@ -138,36 +144,53 @@ struct OnboardingContainerView: View {
.tag(0) .tag(0)
.tabItem { Image(systemName: "hand.wave.fill") } .tabItem { Image(systemName: "hand.wave.fill") }
LookAwaySetupView(settingsManager: settingsManager) MenuBarWelcomeView()
.tag(1) .tag(1)
.tabItem { Image(systemName: "menubar.rectangle") }
LookAwaySetupView(settingsManager: settingsManager)
.tag(2)
.tabItem { Image(systemName: "eye.fill") } .tabItem { Image(systemName: "eye.fill") }
BlinkSetupView(settingsManager: settingsManager) BlinkSetupView(settingsManager: settingsManager)
.tag(2) .tag(3)
.tabItem { Image(systemName: "eye.circle.fill") } .tabItem { Image(systemName: "eye.circle.fill") }
PostureSetupView(settingsManager: settingsManager) PostureSetupView(settingsManager: settingsManager)
.tag(3) .tag(4)
.tabItem { Image(systemName: "figure.stand") } .tabItem { Image(systemName: "figure.stand") }
GeneralSetupView(settingsManager: settingsManager, isOnboarding: true) GeneralSetupView(settingsManager: settingsManager, isOnboarding: true)
.tag(4) .tag(5)
.tabItem { Image(systemName: "gearshape.fill") } .tabItem { Image(systemName: "gearshape.fill") }
CompletionView() CompletionView()
.tag(5) .tag(6)
.tabItem { Image(systemName: "checkmark.circle.fill") } .tabItem { Image(systemName: "checkmark.circle.fill") }
} }
.tabViewStyle(.automatic) .tabViewStyle(.automatic)
.onChange(of: currentPage) { _, newValue in
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: newValue == 1)
}
navigationButtons navigationButtons
} }
} }
#if APPSTORE .frame(
.frame(minWidth: 1000, minHeight: 700) minWidth: 1000,
#else minHeight: {
.frame(minWidth: 1000, minHeight: 900) #if APPSTORE
#endif return 700
#else
return 1000
#endif
}())
.onAppear {
MenuBarGuideOverlayPresenter.shared.updateVisibility(isVisible: currentPage == 1)
}
.onDisappear {
MenuBarGuideOverlayPresenter.shared.hide()
}
} }
@ViewBuilder @ViewBuilder
@@ -190,7 +213,7 @@ struct OnboardingContainerView: View {
} }
Button(action: { Button(action: {
if currentPage == 5 { if currentPage == lastPageIndex {
completeOnboarding() completeOnboarding()
} else { } else {
currentPage += 1 currentPage += 1
@@ -198,7 +221,8 @@ struct OnboardingContainerView: View {
}) { }) {
Text( Text(
currentPage == 0 currentPage == 0
? "Let's Get Started" : currentPage == 5 ? "Get Started" : "Continue" ? "Let's Get Started"
: currentPage == lastPageIndex ? "Get Started" : "Continue"
) )
.font(.headline) .font(.headline)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44) .frame(minWidth: 100, maxWidth: .infinity, minHeight: 44, maxHeight: 44)

View File

@@ -11,7 +11,7 @@ import SwiftUI
final class SettingsWindowPresenter { final class SettingsWindowPresenter {
static let shared = SettingsWindowPresenter() static let shared = SettingsWindowPresenter()
private weak var windowController: NSWindowController? private var windowController: NSWindowController?
private var closeObserver: NSObjectProtocol? private var closeObserver: NSObjectProtocol?
private var isShowingWindow = false private var isShowingWindow = false
@@ -78,8 +78,10 @@ final class SettingsWindowPresenter {
window.setFrameAutosaveName("SettingsWindow") window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary] window.collectionBehavior = [
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
]
window.contentView = NSHostingView( window.contentView = NSHostingView(
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab) rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
) )
@@ -204,8 +206,9 @@ struct SettingsWindowView: View {
#if DEBUG #if DEBUG
private func retriggerOnboarding() { private func retriggerOnboarding() {
SettingsWindowPresenter.shared.close() 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 #endif

View File

@@ -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()
}

View File

@@ -41,11 +41,12 @@ final class OnboardingNavigationTests: XCTestCase {
// Simulate moving through pages // Simulate moving through pages
let pages = [ let pages = [
"Welcome", // 0 "Welcome", // 0
"LookAway", // 1 "MenuBar", // 1
"Blink", // 2 "LookAway", // 2
"Posture", // 3 "Blink", // 3
"General", // 4 "Posture", // 4
"Completion", // 5 "General", // 5
"Completion", // 6
] ]
for (index, pageName) in pages.enumerated() { for (index, pageName) in pages.enumerated() {
@@ -177,7 +178,9 @@ final class OnboardingNavigationTests: XCTestCase {
// Page 0: Welcome - no configuration needed // 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 var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer
lookAwayConfig.enabled = true lookAwayConfig.enabled = true
lookAwayConfig.intervalSeconds = 1200 lookAwayConfig.intervalSeconds = 1200