general: attempts at better window management

This commit is contained in:
Michael Freno
2026-01-17 00:39:38 -05:00
parent f46ccdc4ca
commit 03ab6160d2
7 changed files with 118 additions and 64 deletions

View File

@@ -17,8 +17,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
private var updateManager: UpdateManager? private var updateManager: UpdateManager?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var hasStartedTimers = false private var hasStartedTimers = false
private var isSettingsWindowOpen = false
private var isOnboardingWindowOpen = false
// Convenience accessor for settings // Convenience accessor for settings
private var settingsManager: any SettingsProviding { private var settingsManager: any SettingsProviding {
@@ -51,11 +49,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
serviceContainer.setupSmartModeServices() 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 // Initialize update manager after onboarding is complete
if settingsManager.settings.hasCompletedOnboarding { if settingsManager.settings.hasCompletedOnboarding {
@@ -228,45 +221,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
} }
} }
private let menuDismissalDelay: TimeInterval = 0.1
func openSettings(tab: Int = 0) { func openSettings(tab: Int = 0) {
// If settings window is already open, focus it instead of opening new one performAfterMenuDismissal { [weak self] in
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
guard let self else { return } guard let self else { return }
windowManager.showSettings(settingsManager: self.settingsManager, initialTab: tab) self.windowManager.showSettings(settingsManager: self.settingsManager, initialTab: tab)
} }
} }
func openOnboarding() { func openOnboarding() {
// If onboarding window is already open, focus it instead of opening new one performAfterMenuDismissal { [weak self] in
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
guard let self else { return } 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() windowManager.dismissOverlayReminder()
} }
private func performAfterMenuDismissal(_ action: @escaping () -> Void) {
handleMenuDismissal()
DispatchQueue.main.asyncAfter(deadline: .now() + menuDismissalDelay) {
action()
}
}
private func setupWindowCloseObservers() { private func setupWindowCloseObservers() {
// Observe settings window closing
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
selector: #selector(settingsWindowDidClose), selector: #selector(settingsWindowDidClose),
@@ -284,7 +257,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
object: nil object: nil
) )
// Observe onboarding window closing
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
selector: #selector(onboardingWindowDidClose), selector: #selector(onboardingWindowDidClose),
@@ -293,13 +265,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
) )
} }
@objc private func settingsWindowDidClose() { @objc private func settingsWindowDidClose() {}
isSettingsWindowOpen = false
}
@objc private func onboardingWindowDidClose() { @objc private func onboardingWindowDidClose() {}
// Reset the flag when we receive the close notification
isOnboardingWindowOpen = false
}
} }

View File

@@ -76,6 +76,11 @@ enum EyeTrackingConstants: Sendable {
/// > 1.0 = More aggressive scaling /// > 1.0 = More aggressive scaling
static let distanceSensitivity: Double = 1.0 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. /// Minimum confidence required for a valid pupil detection before updating the gaze average.
/// Helps filter out blinks or noisy frames. /// Helps filter out blinks or noisy frames.
static let minimumGazeConfidence: Int = 3 // consecutive valid frames static let minimumGazeConfidence: Int = 3 // consecutive valid frames

View File

@@ -144,7 +144,7 @@ struct GazeThresholds: Codable {
screenRightBound: 0.20, // Right edge of screen screenRightBound: 0.20, // Right edge of screen
screenTopBound: 0.35, // Top edge of screen screenTopBound: 0.35, // Top edge of screen
screenBottomBound: 0.55, // Bottom 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)
) )
} }
} }

View File

@@ -535,8 +535,23 @@ class EyeTrackingService: NSObject, ObservableObject {
} else { } else {
// Fallback to default constants (no calibration) // Fallback to default constants (no calibration)
let lookingRight = avgH <= EyeTrackingConstants.pixelGazeMinRatio // Still apply distance scaling using default reference
let lookingLeft = avgH >= EyeTrackingConstants.pixelGazeMaxRatio 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 eyesLookingAway = lookingRight || lookingLeft
} }
} }

View File

@@ -32,9 +32,12 @@ final class OnboardingWindowPresenter {
private weak var windowController: NSWindowController? private weak var windowController: NSWindowController?
private var closeObserver: NSObjectProtocol? private var closeObserver: NSObjectProtocol?
private var isShowingWindow = false
func show(settingsManager: SettingsManager) { func show(settingsManager: SettingsManager) {
if activateIfPresent() { return } if activateIfPresent() { return }
guard !isShowingWindow else { return }
isShowingWindow = true
createWindow(settingsManager: settingsManager) createWindow(settingsManager: settingsManager)
} }
@@ -44,15 +47,26 @@ final class OnboardingWindowPresenter {
windowController = nil windowController = nil
return false return false
} }
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true) DispatchQueue.main.async {
window.makeMain() NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true)
if window.isMiniaturized {
window.deminiaturize(nil)
}
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
window.makeMain()
}
return true return true
} }
func close() { func close() {
windowController?.close() windowController?.close()
windowController = nil windowController = nil
isShowingWindow = false
removeCloseObserver() removeCloseObserver()
} }
@@ -69,14 +83,18 @@ final class OnboardingWindowPresenter {
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
window.center() window.center()
window.isReleasedWhenClosed = true window.isReleasedWhenClosed = true
window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary]
window.contentView = NSHostingView( window.contentView = NSHostingView(
rootView: OnboardingContainerView(settingsManager: settingsManager) rootView: OnboardingContainerView(settingsManager: settingsManager)
) )
let controller = NSWindowController(window: window) let controller = NSWindowController(window: window)
controller.showWindow(nil) controller.showWindow(nil)
window.makeKeyAndOrderFront(nil) NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
windowController = controller windowController = controller
@@ -88,6 +106,7 @@ final class OnboardingWindowPresenter {
) { [weak self] _ in ) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
self?.windowController = nil self?.windowController = nil
self?.isShowingWindow = false
self?.removeCloseObserver() self?.removeCloseObserver()
} }
NotificationCenter.default.post( NotificationCenter.default.post(

View File

@@ -13,9 +13,12 @@ final class SettingsWindowPresenter {
private weak var windowController: NSWindowController? private weak var windowController: NSWindowController?
private var closeObserver: NSObjectProtocol? private var closeObserver: NSObjectProtocol?
private var isShowingWindow = false
func show(settingsManager: SettingsManager, initialTab: Int = 0) { func show(settingsManager: SettingsManager, initialTab: Int = 0) {
if focusExistingWindow(tab: initialTab) { return } if focusExistingWindow(tab: initialTab) { return }
guard !isShowingWindow else { return }
isShowingWindow = true
createWindow(settingsManager: settingsManager, initialTab: initialTab) createWindow(settingsManager: settingsManager, initialTab: initialTab)
} }
@@ -26,6 +29,7 @@ final class SettingsWindowPresenter {
func close() { func close() {
windowController?.close() windowController?.close()
windowController = nil windowController = nil
isShowingWindow = false
removeCloseObserver() removeCloseObserver()
} }
@@ -36,15 +40,24 @@ final class SettingsWindowPresenter {
return false return false
} }
if let tab { DispatchQueue.main.async {
NotificationCenter.default.post( if let tab {
name: Notification.Name("SwitchToSettingsTab"), NotificationCenter.default.post(
object: tab name: Notification.Name("SwitchToSettingsTab"),
) object: tab
} )
}
window.makeKeyAndOrderFront(nil) NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
if window.isMiniaturized {
window.deminiaturize(nil)
}
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
return true return true
} }
@@ -65,14 +78,18 @@ final class SettingsWindowPresenter {
window.setFrameAutosaveName("SettingsWindow") window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary]
window.contentView = NSHostingView( window.contentView = NSHostingView(
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab) rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
) )
let controller = NSWindowController(window: window) let controller = NSWindowController(window: window)
controller.showWindow(nil) controller.showWindow(nil)
window.makeKeyAndOrderFront(nil) NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
windowController = controller windowController = controller
@@ -84,10 +101,12 @@ final class SettingsWindowPresenter {
) { [weak self] _ in ) { [weak self] _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.windowController = nil self?.windowController = nil
self?.isShowingWindow = false
self?.removeCloseObserver() self?.removeCloseObserver()
NotificationCenter.default.post( NotificationCenter.default.post(
name: Notification.Name("SettingsWindowDidClose"), object: nil) name: Notification.Name("SettingsWindowDidClose"), object: nil)
} }
self?.isShowingWindow = false
} }
} }

View File

@@ -18,6 +18,13 @@ final class VideoGazeTests: XCTestCase {
logLines.append(message) 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" /// Process the outer video (looking away from screen) - should detect "looking away"
func testOuterVideoGazeDetection() async throws { func testOuterVideoGazeDetection() async throws {
logLines = [] 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("🎯 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(" 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(" 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 // 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"))") 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("🎯 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(" 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(" 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 // 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"))") 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 maxH = -Double.greatestFiniteMagnitude
var minV = Double.greatestFiniteMagnitude var minV = Double.greatestFiniteMagnitude
var maxV = -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 { private func processVideo(at url: URL, expectLookingAway: Bool) async throws -> VideoStats {
@@ -159,6 +180,13 @@ final class VideoGazeTests: XCTestCase {
stats.faceDetectedFrames += 1 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( let imageSize = CGSize(
width: CVPixelBufferGetWidth(pixelBuffer), width: CVPixelBufferGetWidth(pixelBuffer),
height: CVPixelBufferGetHeight(pixelBuffer) height: CVPixelBufferGetHeight(pixelBuffer)
@@ -220,6 +248,7 @@ final class VideoGazeTests: XCTestCase {
log(String(repeating: "=", count: 75)) log(String(repeating: "=", count: 75))
log("Summary: \(stats.totalFrames) frames sampled, \(stats.faceDetectedFrames) with face, \(stats.pupilDetectedFrames) with pupils") log("Summary: \(stats.totalFrames) frames sampled, \(stats.faceDetectedFrames) with face, \(stats.pupilDetectedFrames) with pupils")
log("Center frames: \(stats.centerFrames), Non-center: \(stats.nonCenterFrames)") 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") log("Processing complete\n")
return stats return stats