ソースを参照

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 週間 前
コミット
ce75d79d9c
共有3 個のファイルを変更した142 個の追加4 個の削除を含む
  1. 3 4
      App for Indeed/AppDelegate.swift
  2. 98 0
      App for Indeed/Services/AppAppearanceManager.swift
  3. 41 0
      App for Indeed/Views/DashboardView.swift

+ 3 - 4
App for Indeed/AppDelegate.swift

@@ -20,8 +20,8 @@ enum AppWindowConfiguration {
20 20
         window.titlebarAppearsTransparent = true
21 21
         window.titleVisibility = .hidden
22 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 26
         let targetContent = NSRect(origin: .zero, size: defaultContentSize)
27 27
         let targetFrame = window.frameRect(forContentRect: targetContent)
@@ -48,8 +48,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
48 48
     private var lastSubscriptionRefreshAt: Date?
49 49
 
50 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 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 119
     private let savedJobsDocumentView = JobListingsDocumentView()
120 120
     private let savedJobsStack = NSStackView()
121 121
     private let settingsPageContainer = NSView()
122
+    private weak var appearanceModeSegment: NSSegmentedControl?
122 123
     private let cvMakerPageContainer = NSView()
123 124
     private lazy var cvMakerPageView: CVMakerPageView = {
124 125
         CVMakerPageView()
@@ -1557,6 +1558,23 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
1557 1558
         contentStack.alignment = .leading
1558 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 1578
         let settingsSection = makeSettingsSection(rows: [
1561 1579
             makeSettingsRow(title: "Share App", systemImage: "square.and.arrow.up", accessory: nil, tapAction: #selector(didTapShareApp)),
1562 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 1598
         aboutStack.alignment = .leading
1581 1599
         aboutStack.translatesAutoresizingMaskIntoConstraints = false
1582 1600
 
1601
+        contentStack.addArrangedSubview(appearanceStack)
1583 1602
         contentStack.addArrangedSubview(settingsSection)
1584 1603
         contentStack.addArrangedSubview(aboutStack)
1585 1604
         settingsPageContainer.addSubview(contentStack)
@@ -1588,6 +1607,8 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
1588 1607
             contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42),
1589 1608
             contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42),
1590 1609
             contentStack.topAnchor.constraint(equalTo: settingsPageContainer.topAnchor, constant: 48),
1610
+            appearanceStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1611
+            appearanceSection.widthAnchor.constraint(equalTo: appearanceStack.widthAnchor),
1591 1612
             settingsSection.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1592 1613
             aboutStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1593 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 1639
     private func makeSettingsSection(rows: [NSView]) -> NSView {
1599 1640
         let section = NSStackView()
1600 1641
         section.orientation = .vertical