general: attempts at better window management
This commit is contained in:
@@ -17,8 +17,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
private var updateManager: UpdateManager?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
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() {}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user