fix: settings nav button bug

This commit is contained in:
Michael Freno
2026-01-14 20:15:24 -05:00
parent 8815315059
commit 8e5f6c6715
4 changed files with 77 additions and 73 deletions

View File

@@ -5,9 +5,9 @@
// Created by Mike Freno on 1/7/26. // Created by Mike Freno on 1/7/26.
// //
import SwiftUI
import AppKit import AppKit
import Combine import Combine
import SwiftUI
@MainActor @MainActor
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@@ -19,36 +19,36 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
private var settingsWindowController: NSWindowController? private var settingsWindowController: NSWindowController?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var hasStartedTimers = false private var hasStartedTimers = false
// Smart Mode services // Smart Mode services
private var fullscreenService: FullscreenDetectionService? private var fullscreenService: FullscreenDetectionService?
private var idleService: IdleMonitoringService? private var idleService: IdleMonitoringService?
private var usageTrackingService: UsageTrackingService? private var usageTrackingService: UsageTrackingService?
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
// Set activation policy to hide dock icon // Set activation policy to hide dock icon
NSApplication.shared.setActivationPolicy(.accessory) NSApplication.shared.setActivationPolicy(.accessory)
timerEngine = TimerEngine(settingsManager: settingsManager) timerEngine = TimerEngine(settingsManager: settingsManager)
// Initialize Smart Mode services // Initialize Smart Mode services
setupSmartModeServices() setupSmartModeServices()
// Initialize update manager after onboarding is complete // Initialize update manager after onboarding is complete
if settingsManager.settings.hasCompletedOnboarding { if settingsManager.settings.hasCompletedOnboarding {
updateManager = UpdateManager.shared updateManager = UpdateManager.shared
} }
setupLifecycleObservers() setupLifecycleObservers()
observeSettingsChanges() observeSettingsChanges()
// Start timers if onboarding is complete // Start timers if onboarding is complete
if settingsManager.settings.hasCompletedOnboarding { if settingsManager.settings.hasCompletedOnboarding {
startTimers() startTimers()
} }
} }
private func setupSmartModeServices() { private func setupSmartModeServices() {
fullscreenService = FullscreenDetectionService() fullscreenService = FullscreenDetectionService()
idleService = IdleMonitoringService( idleService = IdleMonitoringService(
@@ -57,49 +57,50 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
usageTrackingService = UsageTrackingService( usageTrackingService = UsageTrackingService(
resetThresholdMinutes: settingsManager.settings.smartMode.usageResetAfterMinutes resetThresholdMinutes: settingsManager.settings.smartMode.usageResetAfterMinutes
) )
// Connect idle service to usage tracking // Connect idle service to usage tracking
if let idleService = idleService { if let idleService = idleService {
usageTrackingService?.setupIdleMonitoring(idleService) usageTrackingService?.setupIdleMonitoring(idleService)
} }
// Connect services to timer engine // Connect services to timer engine
timerEngine?.setupSmartMode( timerEngine?.setupSmartMode(
fullscreenService: fullscreenService, fullscreenService: fullscreenService,
idleService: idleService idleService: idleService
) )
// Observe smart mode settings changes // Observe smart mode settings changes
settingsManager.$settings settingsManager.$settings
.map { $0.smartMode } .map { $0.smartMode }
.removeDuplicates() .removeDuplicates()
.sink { [weak self] smartMode in .sink { [weak self] smartMode in
self?.idleService?.updateThreshold(minutes: smartMode.idleThresholdMinutes) self?.idleService?.updateThreshold(minutes: smartMode.idleThresholdMinutes)
self?.usageTrackingService?.updateResetThreshold(minutes: smartMode.usageResetAfterMinutes) self?.usageTrackingService?.updateResetThreshold(
minutes: smartMode.usageResetAfterMinutes)
// Force state check when settings change to apply immediately // Force state check when settings change to apply immediately
self?.fullscreenService?.forceUpdate() self?.fullscreenService?.forceUpdate()
self?.idleService?.forceUpdate() self?.idleService?.forceUpdate()
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
func onboardingCompleted() { func onboardingCompleted() {
startTimers() startTimers()
// Start update checks after onboarding // Start update checks after onboarding
if updateManager == nil { if updateManager == nil {
updateManager = UpdateManager.shared updateManager = UpdateManager.shared
} }
} }
private func startTimers() { private func startTimers() {
guard !hasStartedTimers else { return } guard !hasStartedTimers else { return }
hasStartedTimers = true hasStartedTimers = true
timerEngine?.start() timerEngine?.start()
observeReminderEvents() observeReminderEvents()
} }
private func observeSettingsChanges() { private func observeSettingsChanges() {
settingsManager.$settings settingsManager.$settings
.sink { [weak self] settings in .sink { [weak self] settings in
@@ -114,12 +115,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
settingsManager.saveImmediately() settingsManager.saveImmediately()
timerEngine?.stop() timerEngine?.stop()
} }
private func setupLifecycleObservers() { private func setupLifecycleObservers() {
NSWorkspace.shared.notificationCenter.addObserver( NSWorkspace.shared.notificationCenter.addObserver(
self, self,
@@ -127,7 +128,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
name: NSWorkspace.willSleepNotification, name: NSWorkspace.willSleepNotification,
object: nil object: nil
) )
NSWorkspace.shared.notificationCenter.addObserver( NSWorkspace.shared.notificationCenter.addObserver(
self, self,
selector: #selector(systemDidWake), selector: #selector(systemDidWake),
@@ -135,16 +136,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
object: nil object: nil
) )
} }
@objc private func systemWillSleep() { @objc private func systemWillSleep() {
timerEngine?.handleSystemSleep() timerEngine?.handleSystemSleep()
settingsManager.saveImmediately() settingsManager.saveImmediately()
} }
@objc private func systemDidWake() { @objc private func systemDidWake() {
timerEngine?.handleSystemWake() timerEngine?.handleSystemWake()
} }
private func observeReminderEvents() { private func observeReminderEvents() {
timerEngine?.$activeReminder timerEngine?.$activeReminder
.sink { [weak self] reminder in .sink { [weak self] reminder in
@@ -156,11 +157,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
private func showReminder(_ event: ReminderEvent) { private func showReminder(_ event: ReminderEvent) {
let contentView: AnyView let contentView: AnyView
let requiresFocus: Bool let requiresFocus: Bool
switch event { switch event {
case .lookAwayTriggered(let countdownSeconds): case .lookAwayTriggered(let countdownSeconds):
contentView = AnyView( contentView = AnyView(
@@ -196,20 +197,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
} else { } else {
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
contentView = AnyView( contentView = AnyView(
UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { [weak self] in UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) {
[weak self] in
self?.timerEngine?.dismissReminder() self?.timerEngine?.dismissReminder()
} }
) )
requiresFocus = false requiresFocus = false
} }
} }
showReminderWindow(contentView, requiresFocus: requiresFocus, isOverlay: requiresFocus) showReminderWindow(contentView, requiresFocus: requiresFocus, isOverlay: requiresFocus)
} }
private func showReminderWindow(_ content: AnyView, requiresFocus: Bool, isOverlay: Bool) { private func showReminderWindow(_ content: AnyView, requiresFocus: Bool, isOverlay: Bool) {
guard let screen = NSScreen.main else { return } guard let screen = NSScreen.main else { return }
let window: NSWindow let window: NSWindow
if requiresFocus { if requiresFocus {
window = KeyableWindow( window = KeyableWindow(
@@ -226,29 +228,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
defer: false defer: false
) )
} }
window.identifier = WindowIdentifiers.reminder window.identifier = WindowIdentifiers.reminder
window.level = .floating window.level = .floating
window.isOpaque = false window.isOpaque = false
window.backgroundColor = .clear window.backgroundColor = .clear
window.contentView = NSHostingView(rootView: content) window.contentView = NSHostingView(rootView: content)
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
// Allow mouse events only for overlay reminders (they need dismiss button) // Allow mouse events only for overlay reminders (they need dismiss button)
// Subtle reminders should be completely transparent to mouse input // Subtle reminders should be completely transparent to mouse input
window.acceptsMouseMovedEvents = requiresFocus window.acceptsMouseMovedEvents = requiresFocus
window.ignoresMouseEvents = !requiresFocus window.ignoresMouseEvents = !requiresFocus
let windowController = NSWindowController(window: window) let windowController = NSWindowController(window: window)
windowController.showWindow(nil) windowController.showWindow(nil)
if requiresFocus { if requiresFocus {
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} else { } else {
window.orderFront(nil) window.orderFront(nil)
} }
// Track overlay and subtle reminders separately // Track overlay and subtle reminders separately
if isOverlay { if isOverlay {
overlayReminderWindowController?.close() overlayReminderWindowController?.close()
@@ -258,53 +260,53 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
subtleReminderWindowController = windowController subtleReminderWindowController = windowController
} }
} }
private func dismissOverlayReminder() { private func dismissOverlayReminder() {
overlayReminderWindowController?.close() overlayReminderWindowController?.close()
overlayReminderWindowController = nil overlayReminderWindowController = nil
} }
private func dismissSubtleReminder() { private func dismissSubtleReminder() {
subtleReminderWindowController?.close() subtleReminderWindowController?.close()
subtleReminderWindowController = nil subtleReminderWindowController = nil
} }
// Public method to open settings window // Public method to open settings window
func openSettings(tab: Int = 0) { func openSettings(tab: Int = 0) {
// Post notification to close menu bar popover // Post notification to close menu bar popover
NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil) NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil)
// Dismiss overlay reminders to prevent them from blocking settings window // Dismiss overlay reminders to prevent them from blocking settings window
// Overlay reminders are at .floating level which would sit above settings // Overlay reminders are at .floating level which would sit above settings
dismissOverlayReminder() dismissOverlayReminder()
// Small delay to allow menu bar to close before opening settings // Small delay to allow menu bar to close before opening settings
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.openSettingsWindow(tab: tab) self?.openSettingsWindow(tab: tab)
} }
} }
// Public method to reopen onboarding window // Public method to reopen onboarding window
func openOnboarding() { func openOnboarding() {
NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil) NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil)
// Dismiss overlay reminders to prevent blocking onboarding window // Dismiss overlay reminders to prevent blocking onboarding window
dismissOverlayReminder() dismissOverlayReminder()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if self.activateWindow(withIdentifier: WindowIdentifiers.onboarding) { if self.activateWindow(withIdentifier: WindowIdentifiers.onboarding) {
return return
} }
let window = NSWindow( let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
backing: .buffered, backing: .buffered,
defer: false defer: false
) )
window.identifier = WindowIdentifiers.onboarding window.identifier = WindowIdentifiers.onboarding
window.titleVisibility = .hidden window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
@@ -313,12 +315,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
window.contentView = NSHostingView( window.contentView = NSHostingView(
rootView: OnboardingContainerView(settingsManager: self.settingsManager) rootView: OnboardingContainerView(settingsManager: self.settingsManager)
) )
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
} }
private func openSettingsWindow(tab: Int) { private func openSettingsWindow(tab: Int) {
if let existingWindow = findWindow(withIdentifier: WindowIdentifiers.settings) { if let existingWindow = findWindow(withIdentifier: WindowIdentifiers.settings) {
NotificationCenter.default.post( NotificationCenter.default.post(
@@ -329,30 +331,34 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
return return
} }
let window = NSWindow( let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), contentRect: NSRect(x: 0, y: 0, width: 700, height: 700),
styleMask: [.titled, .closable, .miniaturizable, .resizable], styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, backing: .buffered,
defer: false defer: false
) )
window.identifier = WindowIdentifiers.settings window.identifier = WindowIdentifiers.settings
window.title = "Settings" window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.toolbarStyle = .unified
window.toolbar = NSToolbar()
window.center() window.center()
window.setFrameAutosaveName("SettingsWindow") window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
window.contentView = NSHostingView( window.contentView = NSHostingView(
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: tab) rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: tab)
) )
let windowController = NSWindowController(window: window) let windowController = NSWindowController(window: window)
windowController.showWindow(nil) windowController.showWindow(nil)
settingsWindowController = windowController settingsWindowController = windowController
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
selector: #selector(settingsWindowWillCloseNotification(_:)), selector: #selector(settingsWindowWillCloseNotification(_:)),
@@ -360,16 +366,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
object: window object: window
) )
} }
@objc private func settingsWindowWillCloseNotification(_ notification: Notification) { @objc private func settingsWindowWillCloseNotification(_ notification: Notification) {
settingsWindowController = nil settingsWindowController = nil
} }
/// Finds a window by its identifier /// Finds a window by its identifier
private func findWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> NSWindow? { private func findWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> NSWindow? {
return NSApplication.shared.windows.first { $0.identifier == identifier } return NSApplication.shared.windows.first { $0.identifier == identifier }
} }
/// Brings window to front if it exists, returns true if found /// Brings window to front if it exists, returns true if found
private func activateWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> Bool { private func activateWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> Bool {
guard let window = findWindow(withIdentifier: identifier) else { guard let window = findWindow(withIdentifier: identifier) else {
@@ -386,7 +392,7 @@ class KeyableWindow: NSWindow {
override var canBecomeKey: Bool { override var canBecomeKey: Bool {
return true return true
} }
override var canBecomeMain: Bool { override var canBecomeMain: Bool {
return true return true
} }
@@ -397,7 +403,7 @@ class NonKeyWindow: NSWindow {
override var canBecomeKey: Bool { override var canBecomeKey: Bool {
return false return false
} }
override var canBecomeMain: Bool { override var canBecomeMain: Bool {
return false return false
} }

View File

@@ -25,20 +25,20 @@ enum EyeTrackingConstants {
/// Pitch threshold for looking UP (above screen). /// Pitch threshold for looking UP (above screen).
/// Since camera is at top, looking at screen is negative pitch. /// Since camera is at top, looking at screen is negative pitch.
/// Values > 0.1 imply looking straight ahead or up (away from screen). /// Values > 0.1 imply looking straight ahead or up (away from screen).
static let pitchUpThreshold: Double = 0.1 static let pitchUpThreshold: Double = 0.5
/// Pitch threshold for looking DOWN (at keyboard/lap). /// Pitch threshold for looking DOWN (at keyboard/lap).
/// Values < -0.45 imply looking too far down. /// Values < -0.45 imply looking too far down.
static let pitchDownThreshold: Double = -0.2 static let pitchDownThreshold: Double = -0.9
// MARK: - Pupil Tracking Thresholds // MARK: - Pupil Tracking Thresholds
/// Minimum horizontal pupil ratio (0.0 = right edge, 1.0 = left edge) /// Minimum horizontal pupil ratio (0.0 = right edge, 1.0 = left edge)
/// Values below this are considered looking right (camera view) /// Values below this are considered looking right (camera view)
/// Tightened from 0.25 to 0.35 /// Tightened from 0.25 to 0.35
static let minPupilRatio: Double = 0.35 static let minPupilRatio: Double = 0.45
/// Maximum horizontal pupil ratio /// Maximum horizontal pupil ratio
/// Values above this are considered looking left (camera view) /// Values above this are considered looking left (camera view)
/// Tightened from 0.75 to 0.65 /// Tightened from 0.75 to 0.65
static let maxPupilRatio: Double = 0.65 static let maxPupilRatio: Double = 0.55
} }

View File

@@ -12,12 +12,12 @@ enum SettingsSection: Int, CaseIterable, Identifiable {
case lookAway = 1 case lookAway = 1
case blink = 2 case blink = 2
case posture = 3 case posture = 3
case enforceMode = 4 case userTimers = 4
case userTimers = 5 case enforceMode = 5
case smartMode = 6 case smartMode = 6
var id: Int { rawValue } var id: Int { rawValue }
var title: String { var title: String {
switch self { switch self {
case .general: return "General" case .general: return "General"
@@ -29,7 +29,7 @@ enum SettingsSection: Int, CaseIterable, Identifiable {
case .smartMode: return "Smart Mode" case .smartMode: return "Smart Mode"
} }
} }
var iconName: String { var iconName: String {
switch self { switch self {
case .general: return "gearshape.fill" case .general: return "gearshape.fill"

View File

@@ -24,12 +24,10 @@ struct SettingsWindowView: View {
Label(section.title, systemImage: section.iconName) Label(section.title, systemImage: section.iconName)
} }
} }
.navigationTitle("Settings")
.listStyle(.sidebar) .listStyle(.sidebar)
} detail: { } detail: {
detailView(for: selectedSection) detailView(for: selectedSection)
} }
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 300)
Divider() Divider()