Browse Source

Add System, Light, and Dark theme picker in Settings.

Persist appearance preference and apply it at launch so users can match macOS or force light or dark chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 weeks ago
parent
commit
ce75d79d9c

+ 3 - 4
App for Indeed/AppDelegate.swift

@@ -20,8 +20,8 @@ enum AppWindowConfiguration {
20
         window.titlebarAppearsTransparent = true
20
         window.titlebarAppearsTransparent = true
21
         window.titleVisibility = .hidden
21
         window.titleVisibility = .hidden
22
         window.isMovableByWindowBackground = true
22
         window.isMovableByWindowBackground = true
23
-        // Same as `DashboardView` chrome — avoids a white halo outside the grey frame with fullSizeContentView.
24
-        window.backgroundColor = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
23
+        // Same as dashboard chrome — avoids a halo outside the frame with fullSizeContentView.
24
+        window.backgroundColor = AppAppearanceManager.shared.windowChromeColor
25
 
25
 
26
         let targetContent = NSRect(origin: .zero, size: defaultContentSize)
26
         let targetContent = NSRect(origin: .zero, size: defaultContentSize)
27
         let targetFrame = window.frameRect(forContentRect: targetContent)
27
         let targetFrame = window.frameRect(forContentRect: targetContent)
@@ -48,8 +48,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
48
     private var lastSubscriptionRefreshAt: Date?
48
     private var lastSubscriptionRefreshAt: Date?
49
 
49
 
50
     func applicationWillFinishLaunching(_ notification: Notification) {
50
     func applicationWillFinishLaunching(_ notification: Notification) {
51
-        // Dashboard is light-themed; without this, a Dark Mode Mac paints a dark title bar.
52
-        NSApp.appearance = NSAppearance(named: .aqua)
51
+        AppAppearanceManager.shared.apply()
53
     }
52
     }
54
 
53
 
55
     func applicationDidFinishLaunching(_ aNotification: Notification) {
54
     func applicationDidFinishLaunching(_ aNotification: Notification) {

+ 98 - 0
App for Indeed/Services/AppAppearanceManager.swift

@@ -0,0 +1,98 @@
1
+//
2
+//  AppAppearanceManager.swift
3
+//  App for Indeed
4
+//
5
+
6
+import AppKit
7
+
8
+/// Persists and applies the user’s light / dark / system appearance preference.
9
+@MainActor
10
+final class AppAppearanceManager {
11
+    static let shared = AppAppearanceManager()
12
+
13
+    static let didChangeNotification = Notification.Name("AppAppearanceManager.didChange")
14
+
15
+    enum Mode: String, CaseIterable {
16
+        case light
17
+        case dark
18
+        case system
19
+
20
+        var segmentIndex: Int {
21
+            switch self {
22
+            case .system: 0
23
+            case .light: 1
24
+            case .dark: 2
25
+            }
26
+        }
27
+
28
+        init?(segmentIndex: Int) {
29
+            switch segmentIndex {
30
+            case 0: self = .system
31
+            case 1: self = .light
32
+            case 2: self = .dark
33
+            default: return nil
34
+            }
35
+        }
36
+    }
37
+
38
+    private enum UserDefaultsKey {
39
+        static let appearanceMode = "com.appforindeed.appearanceMode"
40
+    }
41
+
42
+    private var systemThemeObserver: NSObjectProtocol?
43
+
44
+    private init() {
45
+        systemThemeObserver = DistributedNotificationCenter.default().addObserver(
46
+            forName: Notification.Name("AppleInterfaceThemeChangedNotification"),
47
+            object: nil,
48
+            queue: .main
49
+        ) { [weak self] _ in
50
+            guard let self, self.mode == .system else { return }
51
+            self.updateWindowChrome()
52
+        }
53
+    }
54
+
55
+    var mode: Mode {
56
+        get {
57
+            guard let raw = UserDefaults.standard.string(forKey: UserDefaultsKey.appearanceMode),
58
+                  let stored = Mode(rawValue: raw) else {
59
+                return .light
60
+            }
61
+            return stored
62
+        }
63
+        set {
64
+            guard newValue != mode else { return }
65
+            UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.appearanceMode)
66
+            apply()
67
+            NotificationCenter.default.post(name: Self.didChangeNotification, object: self)
68
+        }
69
+    }
70
+
71
+    /// Window backing color aligned with dashboard chrome for the active appearance.
72
+    var windowChromeColor: NSColor {
73
+        let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
74
+        if isDark {
75
+            return NSColor(srgbRed: 28 / 255, green: 28 / 255, blue: 30 / 255, alpha: 1)
76
+        }
77
+        return NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
78
+    }
79
+
80
+    func apply() {
81
+        switch mode {
82
+        case .light:
83
+            NSApp.appearance = NSAppearance(named: .aqua)
84
+        case .dark:
85
+            NSApp.appearance = NSAppearance(named: .darkAqua)
86
+        case .system:
87
+            NSApp.appearance = nil
88
+        }
89
+        updateWindowChrome()
90
+    }
91
+
92
+    private func updateWindowChrome() {
93
+        let color = windowChromeColor
94
+        for window in NSApp.windows where window.isVisible || window.canBecomeKey {
95
+            window.backgroundColor = color
96
+        }
97
+    }
98
+}

+ 41 - 0
App for Indeed/Views/DashboardView.swift

@@ -119,6 +119,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
119
     private let savedJobsDocumentView = JobListingsDocumentView()
119
     private let savedJobsDocumentView = JobListingsDocumentView()
120
     private let savedJobsStack = NSStackView()
120
     private let savedJobsStack = NSStackView()
121
     private let settingsPageContainer = NSView()
121
     private let settingsPageContainer = NSView()
122
+    private weak var appearanceModeSegment: NSSegmentedControl?
122
     private let cvMakerPageContainer = NSView()
123
     private let cvMakerPageContainer = NSView()
123
     private lazy var cvMakerPageView: CVMakerPageView = {
124
     private lazy var cvMakerPageView: CVMakerPageView = {
124
         CVMakerPageView()
125
         CVMakerPageView()
@@ -1557,6 +1558,23 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
1557
         contentStack.alignment = .leading
1558
         contentStack.alignment = .leading
1558
         contentStack.translatesAutoresizingMaskIntoConstraints = false
1559
         contentStack.translatesAutoresizingMaskIntoConstraints = false
1559
 
1560
 
1561
+        let appearanceTitle = NSTextField(labelWithString: "Appearance")
1562
+        appearanceTitle.font = .systemFont(ofSize: 12, weight: .semibold)
1563
+        appearanceTitle.textColor = Theme.secondaryText
1564
+        appearanceTitle.alignment = .left
1565
+
1566
+        let themeSegment = makeAppearanceModeSegment()
1567
+        appearanceModeSegment = themeSegment
1568
+        let appearanceSection = makeSettingsSection(rows: [
1569
+            makeSettingsRow(title: "Theme", systemImage: "circle.lefthalf.filled", accessory: themeSegment, tapAction: nil)
1570
+        ])
1571
+
1572
+        let appearanceStack = NSStackView(views: [appearanceTitle, appearanceSection])
1573
+        appearanceStack.orientation = .vertical
1574
+        appearanceStack.spacing = 14
1575
+        appearanceStack.alignment = .leading
1576
+        appearanceStack.translatesAutoresizingMaskIntoConstraints = false
1577
+
1560
         let settingsSection = makeSettingsSection(rows: [
1578
         let settingsSection = makeSettingsSection(rows: [
1561
             makeSettingsRow(title: "Share App", systemImage: "square.and.arrow.up", accessory: nil, tapAction: #selector(didTapShareApp)),
1579
             makeSettingsRow(title: "Share App", systemImage: "square.and.arrow.up", accessory: nil, tapAction: #selector(didTapShareApp)),
1562
             makeSettingsRow(title: "More Apps", systemImage: "square.grid.2x2", accessory: nil, tapAction: #selector(didTapMoreApps))
1580
             makeSettingsRow(title: "More Apps", systemImage: "square.grid.2x2", accessory: nil, tapAction: #selector(didTapMoreApps))
@@ -1580,6 +1598,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
1580
         aboutStack.alignment = .leading
1598
         aboutStack.alignment = .leading
1581
         aboutStack.translatesAutoresizingMaskIntoConstraints = false
1599
         aboutStack.translatesAutoresizingMaskIntoConstraints = false
1582
 
1600
 
1601
+        contentStack.addArrangedSubview(appearanceStack)
1583
         contentStack.addArrangedSubview(settingsSection)
1602
         contentStack.addArrangedSubview(settingsSection)
1584
         contentStack.addArrangedSubview(aboutStack)
1603
         contentStack.addArrangedSubview(aboutStack)
1585
         settingsPageContainer.addSubview(contentStack)
1604
         settingsPageContainer.addSubview(contentStack)
@@ -1588,6 +1607,8 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
1588
             contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42),
1607
             contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42),
1589
             contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42),
1608
             contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42),
1590
             contentStack.topAnchor.constraint(equalTo: settingsPageContainer.topAnchor, constant: 48),
1609
             contentStack.topAnchor.constraint(equalTo: settingsPageContainer.topAnchor, constant: 48),
1610
+            appearanceStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1611
+            appearanceSection.widthAnchor.constraint(equalTo: appearanceStack.widthAnchor),
1591
             settingsSection.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1612
             settingsSection.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1592
             aboutStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1613
             aboutStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1593
             aboutSection.widthAnchor.constraint(equalTo: aboutStack.widthAnchor),
1614
             aboutSection.widthAnchor.constraint(equalTo: aboutStack.widthAnchor),
@@ -1595,6 +1616,26 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
1595
         ])
1616
         ])
1596
     }
1617
     }
1597
 
1618
 
1619
+    private func makeAppearanceModeSegment() -> NSSegmentedControl {
1620
+        let segment = NSSegmentedControl(
1621
+            labels: ["System", "Light", "Dark"],
1622
+            trackingMode: .selectOne,
1623
+            target: self,
1624
+            action: #selector(appearanceModeChanged(_:))
1625
+        )
1626
+        segment.translatesAutoresizingMaskIntoConstraints = false
1627
+        segment.segmentStyle = .automatic
1628
+        segment.selectedSegment = AppAppearanceManager.shared.mode.segmentIndex
1629
+        segment.setContentHuggingPriority(.required, for: .horizontal)
1630
+        segment.setContentCompressionResistancePriority(.required, for: .horizontal)
1631
+        return segment
1632
+    }
1633
+
1634
+    @objc private func appearanceModeChanged(_ sender: NSSegmentedControl) {
1635
+        guard let mode = AppAppearanceManager.Mode(segmentIndex: sender.selectedSegment) else { return }
1636
+        AppAppearanceManager.shared.mode = mode
1637
+    }
1638
+
1598
     private func makeSettingsSection(rows: [NSView]) -> NSView {
1639
     private func makeSettingsSection(rows: [NSView]) -> NSView {
1599
         let section = NSStackView()
1640
         let section = NSStackView()
1600
         section.orientation = .vertical
1641
         section.orientation = .vertical