diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index de1fa17..1f045e1 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -17,8 +17,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private var updateManager: UpdateManager? private var cancellables = Set() private var hasStartedTimers = false - private var isSettingsWindowOpen = false - private var isOnboardingWindowOpen = false // Convenience accessor for settings private var settingsManager: any SettingsProviding { @@ -51,11 +49,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { serviceContainer.setupSmartModeServices() - // Check if onboarding needs to be shown automatically - if !settingsManager.settings.hasCompletedOnboarding { - // Set the flag to indicate we expect an onboarding window - isOnboardingWindowOpen = true - } // Initialize update manager after onboarding is complete if settingsManager.settings.hasCompletedOnboarding { @@ -228,45 +221,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } + private let menuDismissalDelay: TimeInterval = 0.1 + func openSettings(tab: Int = 0) { - // If settings window is already open, focus it instead of opening new one - if isSettingsWindowOpen { - // Try to focus existing window - DispatchQueue.main.async { - NotificationCenter.default.post( - name: Notification.Name("SwitchToSettingsTab"), - object: tab - ) - } - return - } - - handleMenuDismissal() - isSettingsWindowOpen = true - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + performAfterMenuDismissal { [weak self] in guard let self else { return } - windowManager.showSettings(settingsManager: self.settingsManager, initialTab: tab) + self.windowManager.showSettings(settingsManager: self.settingsManager, initialTab: tab) } } func openOnboarding() { - // If onboarding window is already open, focus it instead of opening new one - if isOnboardingWindowOpen { - // Try to activate existing window - DispatchQueue.main.async { - OnboardingWindowPresenter.shared.activateIfPresent() - } - return - } - - handleMenuDismissal() - // Explicitly set the flag to true when we're about to show the onboarding window - isOnboardingWindowOpen = true - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + performAfterMenuDismissal { [weak self] in guard let self else { return } - windowManager.showOnboarding(settingsManager: self.settingsManager) + self.windowManager.showOnboarding(settingsManager: self.settingsManager) } } @@ -275,8 +242,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { windowManager.dismissOverlayReminder() } + private func performAfterMenuDismissal(_ action: @escaping () -> Void) { + handleMenuDismissal() + DispatchQueue.main.asyncAfter(deadline: .now() + menuDismissalDelay) { + action() + } + } + private func setupWindowCloseObservers() { - // Observe settings window closing NotificationCenter.default.addObserver( self, selector: #selector(settingsWindowDidClose), @@ -284,7 +257,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { object: nil ) - // Observe onboarding window closing NotificationCenter.default.addObserver( self, selector: #selector(onboardingWindowDidClose), @@ -293,13 +265,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { ) } - @objc private func settingsWindowDidClose() { - isSettingsWindowOpen = false - } + @objc private func settingsWindowDidClose() {} - @objc private func onboardingWindowDidClose() { - // Reset the flag when we receive the close notification - isOnboardingWindowOpen = false - } + @objc private func onboardingWindowDidClose() {} } diff --git a/Gaze/Constants/EyeTrackingConstants.swift b/Gaze/Constants/EyeTrackingConstants.swift index 65dc369..a408228 100644 --- a/Gaze/Constants/EyeTrackingConstants.swift +++ b/Gaze/Constants/EyeTrackingConstants.swift @@ -76,6 +76,11 @@ enum EyeTrackingConstants: Sendable { /// > 1.0 = More aggressive scaling static let distanceSensitivity: Double = 1.0 + /// Default reference face width for distance scaling when uncalibrated. + /// Measured from test videos at typical laptop distance (~60cm). + /// Face bounding box width as ratio of image width. + static let defaultReferenceFaceWidth: Double = 0.4566 + /// Minimum confidence required for a valid pupil detection before updating the gaze average. /// Helps filter out blinks or noisy frames. static let minimumGazeConfidence: Int = 3 // consecutive valid frames diff --git a/Gaze/Models/CalibrationData.swift b/Gaze/Models/CalibrationData.swift index 8d3d98b..71d850e 100644 --- a/Gaze/Models/CalibrationData.swift +++ b/Gaze/Models/CalibrationData.swift @@ -144,7 +144,7 @@ struct GazeThresholds: Codable { screenRightBound: 0.20, // Right edge of screen screenTopBound: 0.35, // Top edge of screen screenBottomBound: 0.55, // Bottom edge of screen - referenceFaceWidth: 0.0 // 0.0 means unused/uncalibrated + referenceFaceWidth: 0.4566 // Measured from test videos (avg of inner/outer) ) } } diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift index f434481..12c990f 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTrackingService.swift @@ -535,8 +535,23 @@ class EyeTrackingService: NSObject, ObservableObject { } else { // Fallback to default constants (no calibration) - let lookingRight = avgH <= EyeTrackingConstants.pixelGazeMinRatio - let lookingLeft = avgH >= EyeTrackingConstants.pixelGazeMaxRatio + // Still apply distance scaling using default reference + let currentFaceWidth = face.boundingBox.width + let refFaceWidth = EyeTrackingConstants.defaultReferenceFaceWidth + + var distanceScale = 1.0 + if refFaceWidth > 0 && currentFaceWidth > 0 { + let rawScale = refFaceWidth / currentFaceWidth + distanceScale = 1.0 + (rawScale - 1.0) * EyeTrackingConstants.distanceSensitivity + distanceScale = max(0.5, min(2.0, distanceScale)) + } + + // Center is assumed at midpoint of the thresholds + let centerH = (EyeTrackingConstants.pixelGazeMinRatio + EyeTrackingConstants.pixelGazeMaxRatio) / 2.0 + let normalizedH = centerH + (avgH - centerH) * distanceScale + + let lookingRight = normalizedH <= EyeTrackingConstants.pixelGazeMinRatio + let lookingLeft = normalizedH >= EyeTrackingConstants.pixelGazeMaxRatio eyesLookingAway = lookingRight || lookingLeft } } diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index 31d7ddb..76e5d93 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -32,9 +32,12 @@ final class OnboardingWindowPresenter { private weak var windowController: NSWindowController? private var closeObserver: NSObjectProtocol? + private var isShowingWindow = false func show(settingsManager: SettingsManager) { if activateIfPresent() { return } + guard !isShowingWindow else { return } + isShowingWindow = true createWindow(settingsManager: settingsManager) } @@ -44,15 +47,26 @@ final class OnboardingWindowPresenter { windowController = nil return false } - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - window.makeMain() + + DispatchQueue.main.async { + NSApp.unhide(nil) + NSApp.activate(ignoringOtherApps: true) + + if window.isMiniaturized { + window.deminiaturize(nil) + } + + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + window.makeMain() + } return true } func close() { windowController?.close() windowController = nil + isShowingWindow = false removeCloseObserver() } @@ -69,14 +83,18 @@ final class OnboardingWindowPresenter { window.titlebarAppearsTransparent = true window.center() window.isReleasedWhenClosed = true + window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary] + window.contentView = NSHostingView( rootView: OnboardingContainerView(settingsManager: settingsManager) ) let controller = NSWindowController(window: window) controller.showWindow(nil) - window.makeKeyAndOrderFront(nil) + NSApp.unhide(nil) NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() windowController = controller @@ -88,6 +106,7 @@ final class OnboardingWindowPresenter { ) { [weak self] _ in Task { @MainActor in self?.windowController = nil + self?.isShowingWindow = false self?.removeCloseObserver() } NotificationCenter.default.post( diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index ce6b3e0..a36a964 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -13,9 +13,12 @@ final class SettingsWindowPresenter { private weak var windowController: NSWindowController? private var closeObserver: NSObjectProtocol? + private var isShowingWindow = false func show(settingsManager: SettingsManager, initialTab: Int = 0) { if focusExistingWindow(tab: initialTab) { return } + guard !isShowingWindow else { return } + isShowingWindow = true createWindow(settingsManager: settingsManager, initialTab: initialTab) } @@ -26,6 +29,7 @@ final class SettingsWindowPresenter { func close() { windowController?.close() windowController = nil + isShowingWindow = false removeCloseObserver() } @@ -36,15 +40,24 @@ final class SettingsWindowPresenter { return false } - if let tab { - NotificationCenter.default.post( - name: Notification.Name("SwitchToSettingsTab"), - object: tab - ) - } + DispatchQueue.main.async { + if let tab { + NotificationCenter.default.post( + name: Notification.Name("SwitchToSettingsTab"), + object: tab + ) + } - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) + NSApp.unhide(nil) + NSApp.activate(ignoringOtherApps: true) + + if window.isMiniaturized { + window.deminiaturize(nil) + } + + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } return true } @@ -65,14 +78,18 @@ final class SettingsWindowPresenter { window.setFrameAutosaveName("SettingsWindow") window.isReleasedWhenClosed = false + window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary] + window.contentView = NSHostingView( rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab) ) let controller = NSWindowController(window: window) controller.showWindow(nil) - window.makeKeyAndOrderFront(nil) + NSApp.unhide(nil) NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() windowController = controller @@ -84,10 +101,12 @@ final class SettingsWindowPresenter { ) { [weak self] _ in Task { @MainActor [weak self] in self?.windowController = nil + self?.isShowingWindow = false self?.removeCloseObserver() NotificationCenter.default.post( name: Notification.Name("SettingsWindowDidClose"), object: nil) } + self?.isShowingWindow = false } } diff --git a/GazeTests/VideoGazeTests.swift b/GazeTests/VideoGazeTests.swift index c9bf571..ca37583 100644 --- a/GazeTests/VideoGazeTests.swift +++ b/GazeTests/VideoGazeTests.swift @@ -18,6 +18,13 @@ final class VideoGazeTests: XCTestCase { logLines.append(message) } + private func attachLogs() { + let attachment = XCTAttachment(string: logLines.joined(separator: "\n")) + attachment.name = "Test Logs" + attachment.lifetime = .keepAlways + add(attachment) + } + /// Process the outer video (looking away from screen) - should detect "looking away" func testOuterVideoGazeDetection() async throws { logLines = [] @@ -34,6 +41,9 @@ final class VideoGazeTests: XCTestCase { log("🎯 OUTER video: \(String(format: "%.1f%%", nonCenterRatio * 100)) frames detected as non-center (expected: >50%)") log(" H-range: \(String(format: "%.3f", stats.minH)) to \(String(format: "%.3f", stats.maxH))") log(" V-range: \(String(format: "%.3f", stats.minV)) to \(String(format: "%.3f", stats.maxV))") + log(" Face width: \(String(format: "%.3f", stats.avgFaceWidth)) (range: \(String(format: "%.3f", stats.minFaceWidth))-\(String(format: "%.3f", stats.maxFaceWidth)))") + + attachLogs() // At least 50% should be detected as non-center when looking away XCTAssertGreaterThan(nonCenterRatio, 0.5, "Looking away video should have >50% non-center detections. Log:\n\(logLines.joined(separator: "\n"))") @@ -55,6 +65,9 @@ final class VideoGazeTests: XCTestCase { log("🎯 INNER video: \(String(format: "%.1f%%", centerRatio * 100)) frames detected as center (expected: >50%)") log(" H-range: \(String(format: "%.3f", stats.minH)) to \(String(format: "%.3f", stats.maxH))") log(" V-range: \(String(format: "%.3f", stats.minV)) to \(String(format: "%.3f", stats.maxV))") + log(" Face width: \(String(format: "%.3f", stats.avgFaceWidth)) (range: \(String(format: "%.3f", stats.minFaceWidth))-\(String(format: "%.3f", stats.maxFaceWidth)))") + + attachLogs() // At least 50% should be detected as center when looking at screen XCTAssertGreaterThan(centerRatio, 0.5, "Looking at screen video should have >50% center detections. Log:\n\(logLines.joined(separator: "\n"))") @@ -70,6 +83,14 @@ final class VideoGazeTests: XCTestCase { var maxH = -Double.greatestFiniteMagnitude var minV = Double.greatestFiniteMagnitude var maxV = -Double.greatestFiniteMagnitude + var minFaceWidth = Double.greatestFiniteMagnitude + var maxFaceWidth = -Double.greatestFiniteMagnitude + var totalFaceWidth = 0.0 + var faceWidthCount = 0 + + var avgFaceWidth: Double { + faceWidthCount > 0 ? totalFaceWidth / Double(faceWidthCount) : 0 + } } private func processVideo(at url: URL, expectLookingAway: Bool) async throws -> VideoStats { @@ -159,6 +180,13 @@ final class VideoGazeTests: XCTestCase { stats.faceDetectedFrames += 1 + // Track face width (bounding box width as ratio of image width) + let faceWidth = face.boundingBox.width + stats.minFaceWidth = min(stats.minFaceWidth, faceWidth) + stats.maxFaceWidth = max(stats.maxFaceWidth, faceWidth) + stats.totalFaceWidth += faceWidth + stats.faceWidthCount += 1 + let imageSize = CGSize( width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer) @@ -220,6 +248,7 @@ final class VideoGazeTests: XCTestCase { log(String(repeating: "=", count: 75)) log("Summary: \(stats.totalFrames) frames sampled, \(stats.faceDetectedFrames) with face, \(stats.pupilDetectedFrames) with pupils") log("Center frames: \(stats.centerFrames), Non-center: \(stats.nonCenterFrames)") + log("Face width: avg=\(String(format: "%.3f", stats.avgFaceWidth)), range=\(String(format: "%.3f", stats.minFaceWidth)) to \(String(format: "%.3f", stats.maxFaceWidth))") log("Processing complete\n") return stats